merge: integrate feat/backlog-burndown into master

master had advanced 16 commits past feat/backlog-burndown's base, and the two
branches independently built four of the same features. Resolved 26 conflicts.

Overlap features — kept master's implementation (more complete / production-wired /
more robust), dropped the feature branch's parallel constellation:
- llama-server health probe: kept master's event-store-backed tps probe; dropped the
  branch's LlamaLivenessClient (liveness-only, throughput unwired).
- event-store probe: kept master's EventStoreHealthProbe; dropped EventStoreLatencyProbe.
- brief echo-back gate: kept master's BriefEchoDiff (Jaccard, tolerates rewording);
  dropped the branch's exact-set-diff BriefEchoComparator/Extractor.
- static-first reviewer: kept master's command/exit-code gate (ProcessStaticAnalysisRunner,
  wired); dropped the branch's structured-finding static_check stage (no-op seam).
  Its structured-findings model is filed as a follow-up in BACKLOG.

Feature-branch net-new work brought in and kept (master had none):
- native task tracking (aggregate, agent tools wired into analyst/implementer/reviewer,
  dependency graph + gates, decompose, REST/CLI, TUI task board)
- critique-outcome producer (role-rel §6 — master had deferred it)
- stage-level plan checkpointing (C-A2, folded into runPostStageGates)
- CLAUDE.md/AGENTS.md L0 standing context
- cross-session grants + TUI (grant scopes/revoke, @ picker, session resume browser)

Verified: full Gradle compile (all modules + tests) green; tests pass for core:events,
core:kernel, infrastructure:workflow, apps:server, apps:cli, testing:integration; tui-go
go build + go test green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-26 11:30:41 +00:00
259 changed files with 17681 additions and 674 deletions
+1
View File
@@ -5,6 +5,7 @@ conventions = [
"Dependency direction: apps -> core -> infrastructure; no cross-core imports",
"No bare try-catch; use runCatching and sealed domain error types",
"Every new EventPayload must be registered in the eventModule polymorphic block (Serialization.kt)",
"Track multi-session or handoff work as native tasks: search before creating to avoid duplicates (task_search), task_context before starting, claim before working, submit_for_review when ready, complete after review. Don't open a task for a single self-contained edit you finish now. When a goal has dependency seams or independent review points, task_decompose it into a parent + DEPENDS_ON-linked children (one approval) instead of one big task; a session works one task at a time, so siblings are claimed by later runs as they unblock.",
]
[commands]
-3
View File
@@ -76,9 +76,6 @@ current.log
# Go TUI build output
apps/tui-go/bin/
# TUI runtime logs (Kotlin TUI, pre-Go rewrite)
apps/tui/logs/
# Server runtime logs
apps/server/logs/
+11
View File
@@ -34,6 +34,17 @@ mutate a candidate in place, or exceed 2 gen rounds / 1 critique round. Defect d
## Ready to build (unblocked)
- [ ] **structured static-analysis findings** (role-rel §5, salvaged from `feat/backlog-burndown` at
the 2026-06-26 merge) — the merge kept master's command/exit-code static gate; the feature
branch's richer model is worth rebuilding on top of it: parse tool output into
`StaticFinding{tool,file,line,column,severity,ruleId}` (detekt/compiler/ktlint parsers existed)
and mechanically strip already-recorded findings from the reviewer's context
(`excludeStaticFindingsFromReview`) instead of only gating on exit code. Needs a production
runner that resolves the command to the session workspace (the original was a no-op seam).
- [ ] **§6 reviewer structured-findings emission** — the critique-outcome producer
(`CritiqueOutcomeCorrelator` → calibration) merged in from `feat/backlog-burndown` is dormant
until the reviewer/plan-critic actually emit `CritiqueFindingsRecordedEvent`s (a `review_report`
findings array + prompt change). Model-dependent → live-QA.
- [ ] **architect contradiction-check** (role-rel §4) — L3 over decision journal + ADRs →
`PossibleContradictionFlag` (display-only in v1).
- [ ] **symbol grounding** (role-rel §3) — needs a real symbol index (file-path grounding done); also
+35
View File
@@ -55,3 +55,38 @@ evidence to check.
`4e5e4e5`, §5 reviewer narrow-question prompt/needs `3467826`.
- **TUI requirements shipped halves** — §2 last-event clock + unknown-event fallback `8b896b5`, §5.1
deterministic command-approval parse card `65df3f4`.
---
## 2026-06-26 — Merged `feat/backlog-burndown` into `master`
`master` had advanced 16 commits past where `feat/backlog-burndown` branched, and the two had
**independently built four of the same features**. The merge kept **master's** implementation for
each overlap (more complete / wired to production / more robust) and dropped the feature branch's
parallel constellation:
- **llama-server health probe** — kept master's event-store-backed tokens/sec probe; dropped the
branch's `LlamaLivenessClient`/`HttpLlamaLivenessClient` (liveness-only, throughput unwired).
- **event-store probe** — kept master's `EventStoreHealthProbe`; dropped the branch's
`EventStoreLatencyProbe`.
- **brief echo-back gate** — kept master's `BriefEchoDiff` (Jaccard token-overlap, tolerant of
rewording); dropped the branch's `BriefEchoComparator`/`BriefEchoExtractor` (exact set-diff).
- **static-first reviewer** — kept master's command/exit-code gate (`StaticAnalysisRunner` +
`ProcessStaticAnalysisRunner`, production-wired); dropped the branch's structured-finding
`static_check` *stage* (`StaticCheckStageExecutor`, `StaticFinding`, `ContextFeedback` exclusion)
which was a no-op pending a production runner. **Its structured-findings model is filed as a
follow-up in BACKLOG** so it isn't lost.
**Net-new feature-branch work brought in and kept** (master had none of these):
- **Native task tracking** — task aggregate + agent tools (`task_create`/`task_update`/
`task_context`/`task_search` wired into analyst/implementer/reviewer), dependency-aware work graph
(blockers/ready), claim/cite/scope/stale-write gates, `task_decompose`, REST surface, CLI, git-driven
status, markdown export, and the TUI task board (`OverlayTasks`, `T` key).
- **Critique-outcome producer (role-rel §6)** — `CritiqueOutcomeCorrelator` +
`CritiqueFindingsRecordedEvent`/`CritiqueOutcomeCorrelatedEvent` + the per-model calibration chain
(master had explicitly *deferred* §6). Dormant until the reviewer emits structured findings (live-QA).
- **Stage-level plan checkpointing (C-A2)** — `emitStageCheckpoint` folded into `runPostStageGates`.
- **L0 standing context** — CLAUDE.md / AGENTS.md injected as agent-instruction context entries.
- **Cross-session grants + TUI** — PROJECT/GLOBAL grant scopes + revoke, grant viewer, `@` file picker,
session resume browser (`OverlaySessions`, `R` key).
@@ -9,6 +9,7 @@ import com.correx.apps.cli.commands.RunCommand
import com.correx.apps.cli.commands.SessionCommand
import com.correx.apps.cli.commands.StatsCommand
import com.correx.apps.cli.commands.StatusCommand
import com.correx.apps.cli.commands.TaskCommand
import com.correx.apps.cli.commands.UndoCommand
import com.github.ajalt.clikt.core.CliktCommand
import com.github.ajalt.clikt.core.subcommands
@@ -33,4 +34,5 @@ fun buildCli(): CorrexCli = CorrexCli().subcommands(
ReplayCommand(),
StatsCommand(),
HealthCommand(),
TaskCommand(),
)
@@ -13,6 +13,7 @@ import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.get
import io.ktor.client.statement.bodyAsText
import io.ktor.serialization.kotlinx.json.json
import java.util.Locale
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
@@ -110,9 +111,10 @@ fun renderStats(report: StatsReportDto): String {
lines += " calls: ${report.inferenceCount}"
lines += " tokens: ${report.promptTokens} prompt + ${report.completionTokens} completion = " +
"${report.promptTokens + report.completionTokens}"
lines += " time: ${report.inferenceMs} ms (%.1f tok/s)".format(report.tokensPerSecond)
lines += " time: ${report.inferenceMs} ms (%.1f tok/s)".format(Locale.ROOT, report.tokensPerSecond)
for (p in report.perProvider) {
lines += " %-20s %4d calls %6d tok %8d ms (%.1f tok/s)".format(
Locale.ROOT,
p.provider, p.completedCount, p.promptTokens + p.completionTokens, p.totalLatencyMs, p.tokensPerSecond,
)
}
@@ -121,7 +123,7 @@ fun renderStats(report: StatsReportDto): String {
lines += "Tools"
lines += " calls: ${report.toolCount} time: ${report.toolMs} ms"
for (t in report.perTool) {
lines += " %-24s %4d %8d ms".format(t.toolName, t.completedCount, t.totalDurationMs)
lines += " %-24s %4d %8d ms".format(Locale.ROOT, t.toolName, t.completedCount, t.totalDurationMs)
}
lines += ""
@@ -131,6 +133,7 @@ fun renderStats(report: StatsReportDto): String {
lines += " total wait: ${report.approvalWaitMs} ms (avg ${report.avgApprovalWaitMs} ms)"
for (tier in report.perTier) {
lines += " %-4s req %d res %d wait %d ms (avg %d ms)".format(
Locale.ROOT,
tier.tier, tier.requestedCount, tier.resolvedCount, tier.totalWaitMs, tier.avgWaitMs,
)
}
@@ -145,6 +148,7 @@ fun renderStats(report: StatsReportDto): String {
lines += "Time accounting (% of session wall time)"
lines += " inference: %.1f%% tools: %.1f%% approval-wait: %.1f%% other: %.1f%%".format(
Locale.ROOT,
report.inferencePct, report.toolPct, report.approvalWaitPct, otherPct(report),
)
@@ -0,0 +1,272 @@
package com.correx.apps.cli.commands
import com.correx.apps.cli.CorrexCli
import com.correx.apps.cli.DEFAULT_PORT
import com.github.ajalt.clikt.core.CliktCommand
import com.github.ajalt.clikt.core.subcommands
import com.github.ajalt.clikt.parameters.arguments.argument
import com.github.ajalt.clikt.parameters.options.default
import com.github.ajalt.clikt.parameters.options.flag
import com.github.ajalt.clikt.parameters.options.multiple
import com.github.ajalt.clikt.parameters.options.option
import com.github.ajalt.clikt.parameters.options.required
import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.get
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType
import io.ktor.http.HttpStatusCode
import io.ktor.http.contentType
import io.ktor.serialization.kotlinx.json.json
import java.io.File
import java.net.URLEncoder
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
private val taskJson = Json { ignoreUnknownKeys = true }
/** Subset of the server's TaskResponse the CLI renders. Unknown fields are ignored. */
@Serializable
data class TaskRow(
val id: String,
val key: String? = null,
val status: String = "",
val title: String? = null,
val goal: String? = null,
val acceptanceCriteria: List<String> = emptyList(),
val affectedPaths: List<String> = emptyList(),
val claimant: String? = null,
val links: List<TaskLinkRow> = emptyList(),
val notes: List<TaskNoteRow> = emptyList(),
)
@Serializable
data class TaskLinkRow(val targetId: String, val type: String, val targetKind: String)
@Serializable
data class TaskNoteRow(val author: String, val body: String)
/** One line per task: id, status, title, and claimant. */
fun renderTaskList(tasks: List<TaskRow>): String {
if (tasks.isEmpty()) return "no tasks"
return tasks.joinToString("\n") { t ->
val claim = t.claimant?.let { " @$it" }.orEmpty()
"${t.id.padEnd(14)} ${t.status.padEnd(12)} ${t.title.orEmpty()}$claim".trimEnd()
}
}
/** Full single-task view: goal, acceptance criteria, affected paths, links, notes. */
fun renderTaskDetail(t: TaskRow): String = buildString {
appendLine("task ${t.id} [${t.status}] ${t.title.orEmpty()}".trimEnd())
t.claimant?.let { appendLine("claimant: $it") }
t.goal?.let { appendLine("goal: $it") }
if (t.acceptanceCriteria.isNotEmpty()) {
appendLine("acceptance criteria:")
t.acceptanceCriteria.forEach { appendLine(" - $it") }
}
if (t.affectedPaths.isNotEmpty()) appendLine("affected paths: ${t.affectedPaths.joinToString(", ")}")
if (t.links.isNotEmpty()) {
appendLine("links:")
t.links.forEach { appendLine(" - ${it.targetId} (${it.type} -> ${it.targetKind})") }
}
if (t.notes.isNotEmpty()) {
appendLine("notes:")
t.notes.forEach { appendLine(" - [${it.author}] ${it.body}") }
}
}.trimEnd()
class TaskCommand : CliktCommand(name = "task") {
override fun run() = Unit
init {
subcommands(
TaskListCommand(),
TaskReadyCommand(),
TaskSearchCommand(),
TaskShowCommand(),
TaskBlockersCommand(),
TaskHistoryCommand(),
TaskCreateCommand(),
TaskClaimCommand(),
TaskCompleteCommand(),
TaskExportCommand(),
)
}
}
/** Shared host/port options + HTTP plumbing for the task subcommands. */
abstract class TaskHttpCommand(name: String) : CliktCommand(name = name) {
protected val host by option("--host").default("localhost")
protected val port by option("--port").default("$DEFAULT_PORT")
protected fun baseUrl(): String = "http://$host:${port.toIntOrNull() ?: DEFAULT_PORT}"
protected fun jsonOut(): Boolean = (currentContext.findRoot().command as? CorrexCli)?.json ?: false
protected fun http(block: suspend (HttpClient) -> Unit) = runBlocking {
val client = HttpClient(CIO) { install(ContentNegotiation) { json(taskJson) } }
runCatching { block(client) }.getOrElse { System.err.println("Error: ${it.message}") }
client.close()
}
}
private fun enc(value: String): String = URLEncoder.encode(value, "UTF-8")
class TaskListCommand : TaskHttpCommand("list") {
private val project by option("--project", help = "Limit to a project")
private val status by option("--status", help = "Filter by status (TODO, IN_PROGRESS, …)")
override fun run() = http { client ->
val params = buildList {
project?.let { add("project=${enc(it)}") }
status?.let { add("status=${enc(it)}") }
}
val query = if (params.isEmpty()) "" else "?${params.joinToString("&")}"
val raw = client.get("${baseUrl()}/tasks$query").bodyAsText()
if (jsonOut()) println(raw) else println(renderTaskList(taskJson.decodeFromString(raw)))
}
}
class TaskReadyCommand : TaskHttpCommand("ready") {
private val project by option("--project", help = "Limit to a project")
override fun run() = http { client ->
val query = project?.let { "&project=${enc(it)}" }.orEmpty()
val raw = client.get("${baseUrl()}/tasks?ready=true$query").bodyAsText()
if (jsonOut()) println(raw) else println(renderTaskList(taskJson.decodeFromString(raw)))
}
}
class TaskBlockersCommand : TaskHttpCommand("blockers") {
private val id by argument("TASK_ID")
override fun run() = http { client ->
val resp = client.get("${baseUrl()}/tasks/$id/blockers")
if (resp.status == HttpStatusCode.NotFound) {
System.err.println("No such task: $id")
} else {
val raw = resp.bodyAsText()
if (jsonOut()) println(raw) else println(renderTaskList(taskJson.decodeFromString(raw)))
}
}
}
class TaskSearchCommand : TaskHttpCommand("search") {
private val query by argument("QUERY", help = "Search text (quote multi-word queries)")
private val project by option("--project", help = "Limit to a project")
override fun run() = http { client ->
val params = buildList {
add("q=${enc(query)}")
project?.let { add("project=${enc(it)}") }
}
val raw = client.get("${baseUrl()}/tasks?${params.joinToString("&")}").bodyAsText()
if (jsonOut()) println(raw) else println(renderTaskList(taskJson.decodeFromString(raw)))
}
}
class TaskShowCommand : TaskHttpCommand("show") {
private val id by argument("TASK_ID")
override fun run() = http { client ->
val resp = client.get("${baseUrl()}/tasks/$id")
if (resp.status == HttpStatusCode.NotFound) {
System.err.println("No such task: $id")
} else {
val raw = resp.bodyAsText()
if (jsonOut()) println(raw) else println(renderTaskDetail(taskJson.decodeFromString(raw)))
}
}
}
class TaskHistoryCommand : TaskHttpCommand("history") {
private val id by argument("TASK_ID")
override fun run() = http { client ->
val resp = client.get("${baseUrl()}/tasks/$id/history")
if (resp.status == HttpStatusCode.NotFound) {
System.err.println("No such task: $id")
} else {
print(resp.bodyAsText())
}
}
}
class TaskCreateCommand : TaskHttpCommand("create") {
private val project by option("--project", help = "Project key, e.g. 'auth'").required()
private val title by option("--title").required()
private val goal by option("--goal").required()
private val criteria by option("--criteria", help = "Acceptance criterion (repeatable)").multiple()
private val paths by option("--path", help = "Affected path/glob (repeatable)").multiple()
private val force by option("--force", help = "Create even if a same-title task exists").flag()
override fun run() = http { client ->
val payload = buildJsonObject {
put("project", project)
put("title", title)
put("goal", goal)
if (criteria.isNotEmpty()) put("acceptanceCriteria", JsonArray(criteria.map { JsonPrimitive(it) }))
if (paths.isNotEmpty()) put("affectedPaths", JsonArray(paths.map { JsonPrimitive(it) }))
if (force) put("force", true)
}
val resp = client.post("${baseUrl()}/tasks") {
contentType(ContentType.Application.Json)
setBody(payload)
}
val raw = resp.bodyAsText()
when {
resp.status == HttpStatusCode.Conflict ->
System.err.println("Possible duplicate(s) — pass --force to create anyway:\n${renderTaskList(taskJson.decodeFromString(raw))}")
jsonOut() -> println(raw)
else -> println("created ${taskJson.decodeFromString<TaskRow>(raw).id}")
}
}
}
class TaskClaimCommand : TaskHttpCommand("claim") {
private val id by argument("TASK_ID")
private val by by option("--by", help = "Claimant").required()
override fun run() = http { client ->
val resp = client.post("${baseUrl()}/tasks/$id/claim") {
contentType(ContentType.Application.Json)
setBody(buildJsonObject { put("claimant", by) })
}
if (resp.status == HttpStatusCode.NotFound) System.err.println("No such task: $id") else println("claimed $id")
}
}
class TaskCompleteCommand : TaskHttpCommand("complete") {
private val id by argument("TASK_ID")
override fun run() = http { client ->
val resp = client.post("${baseUrl()}/tasks/$id/complete")
if (resp.status == HttpStatusCode.NotFound) System.err.println("No such task: $id") else println("completed $id")
}
}
class TaskExportCommand : TaskHttpCommand("export") {
private val project by option("--project", help = "Limit to a project")
private val out by option("--out", help = "Write to this file instead of stdout")
override fun run() = http { client ->
val query = project?.let { "?project=${enc(it)}" }.orEmpty()
val markdown = client.get("${baseUrl()}/tasks/export$query").bodyAsText()
val target = out
if (target != null) {
File(target).writeText(markdown)
println("wrote $target")
} else {
print(markdown)
}
}
}
@@ -0,0 +1,45 @@
package com.correx.apps.cli.commands
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
class TaskRenderTest {
private val task = TaskRow(
id = "auth-1",
key = "auth-1",
status = "IN_PROGRESS",
title = "JWT refresh",
goal = "users stay authenticated",
acceptanceCriteria = listOf("rotates"),
affectedPaths = listOf("backend/auth/**"),
claimant = "claude-opus",
links = listOf(TaskLinkRow("adr-7", "IMPLEMENTS", "DOC")),
notes = listOf(TaskNoteRow("AGENT", "kickoff")),
)
@Test
fun `renderTaskList shows one line per task with id, status, title`() {
val out = renderTaskList(listOf(task, TaskRow(id = "billing-3", status = "DONE", title = "Invoice")))
val lines = out.lines()
assertEquals(2, lines.size)
assertTrue(lines[0].startsWith("auth-1"))
assertTrue(lines[0].contains("IN_PROGRESS") && lines[0].contains("JWT refresh") && lines[0].contains("@claude-opus"))
assertTrue(lines[1].contains("billing-3") && lines[1].contains("DONE"))
}
@Test
fun `renderTaskList reports empty`() {
assertEquals("no tasks", renderTaskList(emptyList()))
}
@Test
fun `renderTaskDetail includes goal, criteria, links and notes`() {
val out = renderTaskDetail(task)
assertTrue(out.startsWith("task auth-1 [IN_PROGRESS] JWT refresh"))
for (want in listOf("goal: users stay authenticated", "- rotates", "backend/auth/**", "adr-7 (IMPLEMENTS -> DOC)", "[AGENT] kickoff")) {
assertTrue(out.contains(want), "detail missing: $want")
}
}
}
-14
View File
@@ -1,14 +0,0 @@
plugins {
id 'org.jetbrains.kotlin.jvm'
id 'application'
}
application {
mainClass = 'com.correx.apps.desktop.MainKt'
}
dependencies {
implementation "com.github.ajalt.clikt:clikt:5.0.1"
}
tasks.named("koverVerify").configure { enabled = false }
@@ -1,5 +0,0 @@
package com.correx.apps.desktop
fun main() {
println("correx :: apps/desktop")
}
+1
View File
@@ -20,6 +20,7 @@ dependencies {
implementation project(':core:events')
implementation project(':core:approvals')
implementation project(':core:sessions')
implementation project(':core:tasks')
implementation project(':core:kernel')
implementation project(':core:journal')
implementation project(':core:inference')
@@ -3,6 +3,7 @@ package com.correx.apps.server
import com.correx.apps.server.health.HealthInspectionService
import com.correx.apps.server.routes.providerRoutes
import com.correx.apps.server.routes.sessionRoutes
import com.correx.apps.server.routes.taskRoutes
import com.correx.apps.server.routes.workflowRoutes
import com.correx.apps.server.ws.GlobalStreamHandler
import io.ktor.http.HttpStatusCode
@@ -57,5 +58,6 @@ fun Application.configureServer(module: ServerModule) {
sessionRoutes(module)
workflowRoutes(module)
providerRoutes(module)
taskRoutes(module)
}
}
@@ -48,6 +48,7 @@ import com.correx.core.sessions.DefaultSessionReducer
import com.correx.core.sessions.DefaultSessionRepository
import com.correx.core.sessions.SessionProjector
import com.correx.core.sessions.projections.replay.DefaultEventReplayer
import com.correx.core.tasks.TaskService
import com.correx.core.tools.registry.ToolRegistry
import com.correx.core.transitions.resolution.DefaultTransitionResolver
import com.correx.core.validation.artifact.ArtifactPayloadValidator
@@ -61,6 +62,10 @@ import com.correx.core.toolintent.rules.ExecInterpreterRule
import com.correx.core.toolintent.rules.ManifestContainmentRule
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.StaleWriteRule
import com.correx.core.toolintent.rules.WriteScopeRule
import com.correx.apps.server.freestyle.FreestyleDriver
import com.correx.apps.server.inference.summarizeWithInference
import com.correx.core.kernel.orchestration.JournalCompactionService
@@ -81,6 +86,7 @@ import com.correx.infrastructure.inference.commons.UnavailableProbe
import com.correx.core.inference.InferenceProvider
import com.correx.infrastructure.inference.llama.cpp.LlamaCppInferenceProvider
import com.correx.infrastructure.tools.DispatchingToolExecutor
import com.correx.infrastructure.tools.task.TaskTools
import com.correx.infrastructure.tools.FileEditConfig
import com.correx.infrastructure.tools.FileReadConfig
import com.correx.infrastructure.tools.FileWriteConfig
@@ -215,6 +221,32 @@ fun main() {
null
},
)
// Agents create/update/delete tasks through the tool system (tier-gated like any tool);
// the service appends to the same event log. Project is derived from the task id prefix.
// The knowledge retriever is late-bound: the L3 retriever isn't built until later in this
// composition root, so the holder is captured now and its delegate set once L3 exists (below).
val taskKnowledgeRetriever = com.correx.apps.server.memory.DeferredTaskKnowledgeRetriever()
// Inlines linked ADRs/docs into the context bundle. The repo root is known here, so unlike the
// L3 retriever this needs no late binding.
val taskDocumentResolver = com.correx.apps.server.tasks.FileTaskDocumentResolver(workspaceRoot)
// Inline ARTIFACT/SESSION link targets too: both read the event log (+ CAS) that already exists
// here, so — like the doc resolver — they need no late binding.
val taskArtifactResolver =
com.correx.apps.server.tasks.EventStoreTaskArtifactResolver(eventStore, artifactStore)
val taskSessionResolver =
com.correx.apps.server.tasks.SessionSummaryTaskSessionResolver(eventStore)
// Backs POST /tasks/sync-git: reads recent commits so a git hook / CI step can drive task status.
val gitCommitReader = com.correx.apps.server.tasks.GitCommandCommitReader(workspaceRoot)
val taskService = TaskService(eventStore)
val taskTools = TaskTools.forService(
taskService,
taskKnowledgeRetriever,
taskDocumentResolver,
taskArtifactResolver,
taskSessionResolver,
com.correx.apps.server.tasks.EventStoreSessionFactRecorder(eventStore),
com.correx.apps.server.tasks.EventStoreSessionWrites(eventStore),
)
val toolRegistry = InfrastructureModule.createToolRegistry(
buildToolConfig(
workspaceRoot,
@@ -223,6 +255,7 @@ fun main() {
toolsConfig,
researchToolConfig,
),
extraTools = taskTools,
)
val toolExecutor = InfrastructureModule.createToolExecutor(
registry = toolRegistry,
@@ -238,6 +271,10 @@ fun main() {
val toolCallAssessor = ToolCallAssessor(
rules = listOf(
PathContainmentRule(),
ReadBeforeWriteRule(),
ReferenceExistsRule(),
WriteScopeRule(),
StaleWriteRule(),
ManifestContainmentRule(),
ExecInterpreterRule(toolsConfig.interpreterExecutables.toSet()),
NetworkHostRule(
@@ -250,6 +287,7 @@ fun main() {
val wsToolRegistryProvider = WorkspaceToolRegistryProvider { workspace ->
val wsRegistry = InfrastructureModule.createToolRegistry(
buildToolConfigForWorkspace(workspace, shellAllowedExecutables, toolsConfig, researchToolConfig),
extraTools = taskTools,
)
val wsExecutor = DispatchingToolExecutor(wsRegistry)
WorkspaceTools(registry = wsRegistry, executor = wsExecutor)
@@ -316,6 +354,10 @@ fun main() {
} else {
null
}
// L3 retriever now exists — bind it into the task context bundle's knowledge port so the
// task_context tool and GET /tasks/{id}/context return semantically-relevant snippets.
taskKnowledgeRetriever.delegate =
repoKnowledgeRetriever?.let { com.correx.apps.server.memory.RepoKnowledgeTaskRetriever(it) }
val orchestrator = DefaultSessionOrchestrator(
repositories = repositories,
engines = engines,
@@ -388,6 +430,16 @@ fun main() {
privilegedLocations = privilegedLocations,
allowedWorkspaceRoots = allowedWorkspaceRoots,
)
// Display-only architect contradiction surfacing (BACKLOG §B-§4): now live via the
// ServerModule.start() subscription on the architect's `design` ArtifactCreatedEvent (see
// ServerModule.handleArchitectArtifact). Gated on project.enabled because the checker queries the
// same L3 "project:<repoRoot>" namespace that ProjectMemoryService.persist populates.
val architectContradictionChecker: com.correx.apps.server.memory.ArchitectContradictionChecker? =
if (correxConfig.project.enabled) {
com.correx.apps.server.memory.ArchitectContradictionChecker(embedder, l3MemoryStore)
} else {
null
}
// Built from a config snapshot and reused by ConfigService's rebuild hook so toggling
// project.enabled / personalization.* applies live to the next session.
fun buildProjectMemory(cfg: CorrexConfig): com.correx.apps.server.memory.ProjectMemoryService? =
@@ -420,7 +472,7 @@ fun main() {
val freestyleDriver = FreestyleDriver(
eventStore = eventStore,
compiler = ExecutionPlanCompiler(artifactKindRegistry),
compiler = ExecutionPlanCompiler(artifactKindRegistry, toolRegistry.all().map { it.name }.toSet()),
planContent = { sid -> orchestrator.validatedArtifactContent(sid, ArtifactId("execution_plan")) },
config = defaultOrchestrationConfig,
runPhase2 = { sid, graph, cfg -> orchestrator.run(sid, graph, cfg) },
@@ -438,7 +490,7 @@ fun main() {
.subjects.mapValues { it.value.status }
com.correx.apps.server.health.HealthMonitor(
eventStore = eventStore,
probes = listOf(
probes = listOfNotNull(
com.correx.apps.server.health.DiskWatermarkProbe(
monitoredPaths = listOf(dataDir),
warnBytes = hc.diskWarnBytes,
@@ -479,11 +531,17 @@ fun main() {
workspaceResolver = workspaceResolver,
narrationMaxPerRun = correxConfig.router.narration.maxPerRun,
projectMemory = projectMemory,
architectContradictionChecker = architectContradictionChecker,
configHolder = configHolder,
freestyleDriver = freestyleDriver,
operatorProfile = operatorProfile,
profileAdaptationService = profileAdaptationService,
healthMonitor = healthMonitor,
taskKnowledgeRetriever = taskKnowledgeRetriever,
taskDocumentResolver = taskDocumentResolver,
taskArtifactResolver = taskArtifactResolver,
taskSessionResolver = taskSessionResolver,
gitCommitReader = gitCommitReader,
)
// Wire live config editing: persist to TOML, swap the holder, and rebuild config-derived
// services. Built after the module so the rebuild hook can swap them in place.
@@ -11,13 +11,19 @@ import com.correx.core.approvals.ApprovalProjector
import com.correx.core.approvals.DefaultApprovalReducer
import com.correx.core.approvals.DefaultApprovalRepository
import com.correx.core.artifactstore.ArtifactStore
import com.correx.core.config.AgentInstructionsLoader
import com.correx.core.config.OperatorProfile
import com.correx.core.config.ProjectProfileLoader
import com.correx.apps.server.memory.ArchitectContradictionChecker
import com.correx.core.events.events.AgentInstructionsBoundEvent
import com.correx.core.events.events.ApprovalRequestedEvent
import com.correx.core.events.events.ArtifactContentStoredEvent
import com.correx.core.events.events.ArtifactCreatedEvent
import com.correx.core.events.events.EventMetadata
import com.correx.core.events.events.NewEvent
import com.correx.core.events.events.ExecutionPlanLockedEvent
import com.correx.core.events.events.OperatorProfileBoundEvent
import com.correx.core.events.events.PossibleContradictionFlaggedEvent
import com.correx.core.events.events.ProjectProfileBoundEvent
import com.correx.core.events.events.OrchestrationPausedEvent
import com.correx.core.events.events.OrchestrationResumedEvent
@@ -26,6 +32,9 @@ import com.correx.core.events.stores.EventStore
import com.correx.core.events.types.EventId
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.jsonObject
import com.correx.core.kernel.execution.WorkflowResult
import com.correx.core.kernel.orchestration.DefaultSessionOrchestrator
import com.correx.core.kernel.orchestration.OrchestrationConfig
@@ -87,6 +96,10 @@ class ServerModule(
// Cross-session, repo-scoped memory. Null disables the hooks entirely (tests / static path).
// Rebuilt by ConfigService when project.enabled is toggled live.
projectMemory: com.correx.apps.server.memory.ProjectMemoryService? = null,
// Display-only architect contradiction surfacing (BACKLOG §B-§4). Null disables the hook
// (tests / project.enabled=false). Unlike projectMemory there is no live config-rebuild hook:
// a server restart re-reads project.enabled, which is enough for this informational flag.
private val architectContradictionChecker: ArchitectContradictionChecker? = null,
// Live, swappable config. Null only in tests that don't exercise config editing; defaults to a
// holder seeded from defaults so callers always have a value to read.
val configHolder: com.correx.core.config.ConfigHolder =
@@ -102,6 +115,17 @@ class ServerModule(
// Continuous health watch (observability-spec §4). Null disables it (tests / health.enabled=false);
// records degraded/restored edge events on the system session, started in [start].
private val healthMonitor: com.correx.apps.server.health.HealthMonitor? = null,
// Semantic enrichment for the task context bundle (GET /tasks/{id}/context). Null disables it.
val taskKnowledgeRetriever: com.correx.core.tasks.TaskKnowledgeRetriever? = null,
// Resolves linked ADRs/docs inline in the task context bundle. Null leaves them as raw links.
val taskDocumentResolver: com.correx.core.tasks.TaskDocumentResolver? = null,
// Resolves linked artifacts inline in the task context bundle. Null leaves them as raw links.
val taskArtifactResolver: com.correx.core.tasks.TaskArtifactResolver? = null,
// Resolves linked agent sessions inline in the task context bundle. Null leaves them as raw links.
val taskSessionResolver: com.correx.core.tasks.TaskSessionResolver? = null,
// Reads recent commits for POST /tasks/sync-git (git-driven status). Null disables the repo read
// (the endpoint then only acts on commits supplied in the request body).
val gitCommitReader: com.correx.core.tasks.GitCommitReader? = null,
) {
val approvalCoordinator: ApprovalCoordinator = approvalCoordinator ?: ApprovalCoordinator(
orchestrator = orchestrator,
@@ -187,6 +211,80 @@ class ServerModule(
// Continuous, edge-triggered health watch (observability-spec §4). Live-only like narration:
// probes read the environment and record degraded/restored events; replay reads those facts.
healthMonitor?.start(moduleScope)
// Display-only architect contradiction surfacing (BACKLOG §B-§4). Live-only like the hooks
// above: subscribeAll() replays nothing and ServerModule is never built under replay, so a
// restart never re-fires the flag (invariant #8). Never halts a stage — failures are logged
// and swallowed (runCatching) and the flag is emitted purely for the operator to eyeball.
architectContradictionChecker?.let { checker ->
eventStore.subscribeAll()
.filter {
it.payload is ArtifactCreatedEvent &&
(it.payload as ArtifactCreatedEvent).stageId.value == ARCHITECT_STAGE_ID
}
.onEach { stored ->
runCatching {
handleArchitectArtifact(checker, stored.payload as ArtifactCreatedEvent)
}.onFailure { log.warn("architect contradiction check failed: {}", it.message) }
}
.launchIn(moduleScope)
}
}
/**
* Runs the architect contradiction check for a freshly-created `design` artifact and, when the
* checker surfaces a non-null flag, appends the [PossibleContradictionFlaggedEvent]. Display-only:
* the flag is informational and never halts the stage. Factored out of the subscription so the
* extraction/dispatch path is unit-testable without a live event flow.
*
* @return the appended flag, or null when the artifact text could not be resolved or the checker
* found nothing near the architect's decision.
*/
internal suspend fun handleArchitectArtifact(
checker: ArchitectContradictionChecker,
event: ArtifactCreatedEvent,
): PossibleContradictionFlaggedEvent? {
val decisionText = resolveArchitectDecisionText(event) ?: return null
val flag = checker.check(event.sessionId, event.stageId, decisionText) ?: return null
eventStore.append(
NewEvent(
metadata = EventMetadata(
eventId = EventId(java.util.UUID.randomUUID().toString()),
sessionId = event.sessionId,
timestamp = Clock.System.now(),
schemaVersion = 1,
causationId = null,
correlationId = null,
),
payload = flag,
),
)
return flag
}
/**
* Resolves the architect's decision text for [event] from the CAS-stored `design` artifact.
* Finds the content hash by scanning the session for the [ArtifactContentStoredEvent] matching
* the artifact id (emitted at inference time, before [ArtifactCreatedEvent]), fetches the bytes,
* and prefers the design schema's `approach` field ("the chosen approach and why") as the
* decision summary; falls back to the whole artifact text, capped at [DECISION_TEXT_CAP] chars.
*
* @return the decision text, or null when no content hash, bytes, or text could be resolved.
*/
private suspend fun resolveArchitectDecisionText(event: ArtifactCreatedEvent): String? {
val contentHash = eventStore.read(event.sessionId)
.asSequence()
.map { it.payload }
.filterIsInstance<ArtifactContentStoredEvent>()
.firstOrNull { it.artifactId == event.artifactId }
?.contentHash ?: return null
val text = artifactStore.get(contentHash)?.toString(Charsets.UTF_8)?.takeIf { it.isNotBlank() }
?: return null
val approach = runCatching {
(lenientJson.parseToJsonElement(text).jsonObject["approach"] as? JsonPrimitive)
?.takeIf { it.isString }?.content
}.getOrNull()?.takeIf { it.isNotBlank() }
return (approach ?: text).take(DECISION_TEXT_CAP)
}
/**
@@ -237,6 +335,7 @@ class ServerModule(
)
}
bindProjectProfile(sessionId)
bindAgentInstructions(sessionId)
runCatching {
val result = orchestrator.run(sessionId, graph, sessionConfig)
freestyleHandoff(sessionId, graph, result)
@@ -320,6 +419,37 @@ class ServerModule(
)
}
/**
* Bind standing agent instructions (CLAUDE.md / AGENTS.md at the workspace root) as an
* event so stages, router chat triage, and replay read the recorded snapshot, never the
* live file (invariants #8/#9). Mirrors [bindProjectProfile].
*/
suspend fun bindAgentInstructions(sessionId: SessionId) {
val workspaceRoot = runCatching {
sessionRepository.getSession(sessionId).state.boundWorkspace?.workspaceRoot
}.getOrNull() ?: projectMemory?.repoRoot() ?: return
val instructions = withContext(Dispatchers.IO) { AgentInstructionsLoader.load(workspaceRoot) }
if (instructions.isEmpty()) return
eventStore.append(
NewEvent(
metadata = EventMetadata(
eventId = EventId(java.util.UUID.randomUUID().toString()),
sessionId = sessionId,
timestamp = Clock.System.now(),
schemaVersion = 1,
causationId = null,
correlationId = null,
),
payload = AgentInstructionsBoundEvent(
sessionId = sessionId,
workspaceRoot = workspaceRoot,
sources = instructions.sources,
content = instructions.content,
),
),
)
}
/**
* Phase-2 handoff for freestyle sessions, shared by every launcher that can finish a
* planning graph (fresh run and all resume paths). Locks the plan and executes it,
@@ -438,4 +568,14 @@ class ServerModule(
.forEach { req -> approvalCoordinator.registerPendingRequest(req.id, sessionId) }
}
}
companion object {
/** Workflow stage id that produces the `design` artifact (examples/workflows/role_pipeline.toml). */
const val ARCHITECT_STAGE_ID = "architect"
/** Cap on the architect decision text embedded when no `approach` field is present. */
private const val DECISION_TEXT_CAP = 4000
private val lenientJson = Json { ignoreUnknownKeys = true; isLenient = true }
}
}
@@ -0,0 +1,70 @@
package com.correx.apps.server.memory
import com.correx.core.events.events.PossibleContradictionFlaggedEvent
import com.correx.core.events.events.RelatedDecision
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import com.correx.core.inference.Embedder
import com.correx.core.router.l3.L3MemoryStore
import com.correx.core.router.l3.L3Query
/**
* Display-only architect contradiction surfacing (BACKLOG §B-§4). When the architect records a
* decision, this embeds the decision text, retrieves semantically-near PRIOR decisions/ADRs from
* L3, and — if any clear the similarity threshold — returns a [PossibleContradictionFlaggedEvent]
* for the operator to eyeball. v1 is purely informational: there is no LLM judge and the caller
* emits the flag without ever halting or failing the stage.
*
* Namespace convention: distilled decision-journal lines are persisted into L3 by
* [ProjectMemoryService] under `turnId = "project:<repoRoot>"` (trailing-`:` delimiter). This is
* the only decision-bearing L3 namespace that exists today, so [decisionNamespacePrefix] defaults
* to `"project:"` — a `startsWith` prefix, matching the trailing-`:` delimiter convention used by
* [L3RepoKnowledgeRetriever]'s `"repomap:<repoRoot>:"` filter. Hits are also constrained to PRIOR
* sessions (`entry.sessionId != sessionId`) so the architect never flags its own in-flight run.
*/
class ArchitectContradictionChecker(
private val embedder: Embedder,
private val l3MemoryStore: L3MemoryStore,
private val k: Int = DEFAULT_K,
private val scoreThreshold: Double = DEFAULT_SCORE_THRESHOLD,
private val decisionNamespacePrefix: String = DEFAULT_DECISION_NAMESPACE_PREFIX,
) {
/**
* @return a [PossibleContradictionFlaggedEvent] listing related prior decisions, or null when
* none clear the threshold. The CALLER emits it (display-only) — this never halts.
*/
suspend fun check(
sessionId: SessionId,
stageId: StageId,
decisionText: String,
): PossibleContradictionFlaggedEvent? {
if (decisionText.isBlank()) return null
val vector = embedder.embed(decisionText)
val related = l3MemoryStore.query(L3Query(vector = vector, k = k * RETRIEVAL_OVERSAMPLE_FACTOR))
.filter { it.entry.turnId.startsWith(decisionNamespacePrefix) }
.filter { it.entry.sessionId != sessionId }
.filter { it.score >= scoreThreshold }
.take(k)
.map {
RelatedDecision(
summary = it.entry.text,
score = it.score.toDouble(),
source = it.entry.turnId,
)
}
if (related.isEmpty()) return null
return PossibleContradictionFlaggedEvent(
sessionId = sessionId,
stageId = stageId,
decisionSummary = decisionText,
related = related,
)
}
companion object {
const val DEFAULT_K = 5
const val DEFAULT_SCORE_THRESHOLD = 0.75
const val DEFAULT_DECISION_NAMESPACE_PREFIX = "project:"
private const val RETRIEVAL_OVERSAMPLE_FACTOR = 4
}
}
@@ -0,0 +1,18 @@
package com.correx.apps.server.memory
import com.correx.core.tasks.KnowledgeHit
import com.correx.core.tasks.TaskKnowledgeRetriever
/**
* Late-bound holder for the task knowledge retriever. The task tools are built at tool-registry
* construction time, which is before the L3 retriever exists in the composition root; this holder
* is created early, captured by the tools and the route, and has its [delegate] set once the L3
* retriever is available. Until then (or when L3 is disabled) it returns no hits.
*/
class DeferredTaskKnowledgeRetriever : TaskKnowledgeRetriever {
@Volatile
var delegate: TaskKnowledgeRetriever? = null
override suspend fun retrieve(query: String, limit: Int): List<KnowledgeHit> =
delegate?.retrieve(query, limit) ?: emptyList()
}
@@ -0,0 +1,19 @@
package com.correx.apps.server.memory
import com.correx.apps.server.health.SYSTEM_SESSION
import com.correx.core.kernel.orchestration.RepoKnowledgeRetriever
import com.correx.core.tasks.KnowledgeHit
import com.correx.core.tasks.TaskKnowledgeRetriever
/**
* Bridges the task context bundle's [TaskKnowledgeRetriever] port to the existing L3 vector
* retriever ([RepoKnowledgeRetriever], typically [L3RepoKnowledgeRetriever]). Tasks are not
* session-scoped for retrieval, so the system session is used (the L3 repo retriever ignores it).
*/
class RepoKnowledgeTaskRetriever(
private val delegate: RepoKnowledgeRetriever,
) : TaskKnowledgeRetriever {
override suspend fun retrieve(query: String, limit: Int): List<KnowledgeHit> =
delegate.retrieve(SYSTEM_SESSION, query, limit)
.map { KnowledgeHit(source = it.path, text = it.text, score = it.score.toDouble()) }
}
@@ -84,6 +84,12 @@ class RepoMapIndexer(
"ts" to Regex("""(?m)^\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?(?:function|class|interface|type|enum)\s+(?<name>[A-Za-z_$][A-Za-z0-9_$]*)"""),
// GDScript: column-0 declarations only, so indented inner-class members never match.
"gd" to Regex("""(?m)^(?:static\s+)?(?:func|class_name|class|signal|enum)\s+(?<name>[A-Za-z_][A-Za-z0-9_]*)"""),
// C# / Godot Mono: top-level type declarations with leading modifiers, kept conservative
// (no method capture — return-type heuristics there are noisy and risk false positives).
"cs" to Regex(
"""(?m)^\s*(?:public |private |protected |internal |static |sealed |abstract |""" +
"""partial )*(?:class|interface|struct|enum|record)\s+(?<name>[A-Za-z_][A-Za-z0-9_]*)""",
),
// Markdown: h1h3 headings as "symbols" — deeper levels are noise relative to retrieval value.
"md" to Regex("""(?m)^#{1,3}\s+(?<name>.+?)\s*$"""),
)
@@ -109,7 +109,7 @@ class RealWorkspaceStateProbe : WorkspaceStateProbe {
.toList()
}
val digest = MessageDigest.getInstance("SHA-256")
lines.forEach { digest.update(it.toByteArray()) }
digest.update(lines.joinToString("\n").toByteArray())
return digest.digest().joinToString("") { "%02x".format(it) }.take(FINGERPRINT_HEX_LENGTH)
}
}
@@ -1,8 +1,10 @@
package com.correx.apps.server.narration
import com.correx.core.events.events.ApprovalDecisionResolvedEvent
import com.correx.core.events.events.ApprovalRequestedEvent
import com.correx.core.events.events.ExecutionPlanRejectedEvent
import com.correx.core.events.events.OrchestrationPausedEvent
import com.correx.core.events.events.OrchestrationResumedEvent
import com.correx.core.events.events.StageCompletedEvent
import com.correx.core.events.events.StageFailedEvent
import com.correx.core.events.events.StoredEvent
@@ -20,6 +22,7 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import org.slf4j.LoggerFactory
import java.util.Collections
import java.util.concurrent.ConcurrentHashMap
class NarrationSubscriber(
@@ -28,7 +31,27 @@ class NarrationSubscriber(
private val scope: CoroutineScope,
private val maxPerRun: Int = DEFAULT_MAX_PER_RUN,
) {
private data class SessionLane(val channel: Channel<NarrationTrigger>, var used: Int)
/**
* A queued narration plus the pause-trigger bookkeeping needed to drop it if the
* pause it described resolves before the lane worker delivers it.
*
* [pauseKey] is non-null only for pause-trigger narrations. It is the resolving signal's
* scope: the originating approval request id when known (per-request supersession), or a
* session-wide sentinel for non-approval pauses (per-session supersession).
*/
private data class QueuedNarration(val trigger: NarrationTrigger, val pauseKey: String?)
/**
* Per-session lane. [pendingPauses] holds the pause keys still queued (not yet delivered
* by the worker). When an approval resolves or the orchestration resumes, the matching
* keys are removed so the worker skips the now-stale pause narration instead of blending
* it with the current status.
*/
private data class SessionLane(
val channel: Channel<QueuedNarration>,
var used: Int,
val pendingPauses: MutableSet<String> = Collections.newSetFromMap(ConcurrentHashMap()),
)
private val lanes = ConcurrentHashMap<String, SessionLane>()
@@ -50,6 +73,7 @@ class NarrationSubscriber(
instruction = approvalInstruction(p),
stageId = p.stageId?.value,
),
pauseKey = pauseKeyForRequest(p.requestId.value),
)
is StageCompletedEvent -> enqueue(
sid,
@@ -100,9 +124,16 @@ class NarrationSubscriber(
instruction = "Orchestration paused in stage ${p.stageId.value}: ${p.reason}. Inform the user.",
stageId = p.stageId.value,
),
pauseKey = SESSION_PAUSE_KEY,
)
}
}
// An approval resolving makes its queued pause narration stale: drop it (scoped to
// that request) so a resolved pause never narrates as if still pending.
is ApprovalDecisionResolvedEvent -> dropPendingPause(sid, pauseKeyForRequest(p.requestId.value))
// Resuming supersedes every pause still queued for the session (approval-pending or
// not): the lane is live again, so any pending "paused" narration is stale.
is OrchestrationResumedEvent -> dropAllPendingPauses(sid)
else -> Unit
}
}
@@ -122,20 +153,46 @@ class NarrationSubscriber(
}
}
private fun enqueue(sessionId: SessionId, trigger: NarrationTrigger) {
private fun enqueue(sessionId: SessionId, trigger: NarrationTrigger, pauseKey: String? = null) {
val lane = lanes.computeIfAbsent(sessionId.value) { startLane(sessionId) }
if (lane.used >= maxPerRun) {
log.debug("narration budget reached for session {}; skipping {}", sessionId.value, trigger.kind)
return
}
lane.used += 1
lane.channel.trySend(trigger)
if (pauseKey != null) {
lane.pendingPauses.add(pauseKey)
}
lane.channel.trySend(QueuedNarration(trigger, pauseKey))
}
/** Marks the pause for [pauseKey] resolved so the lane worker skips it if still queued. */
private fun dropPendingPause(sessionId: SessionId, pauseKey: String) {
lanes[sessionId.value]?.pendingPauses?.remove(pauseKey)
}
/** Marks every queued pause for the session resolved (used on resume). */
private fun dropAllPendingPauses(sessionId: SessionId) {
lanes[sessionId.value]?.pendingPauses?.clear()
}
private fun startLane(sessionId: SessionId): SessionLane {
val channel = Channel<NarrationTrigger>(capacity = Channel.UNLIMITED)
val channel = Channel<QueuedNarration>(capacity = Channel.UNLIMITED)
val lane = SessionLane(channel, used = 0)
scope.launch {
for (trigger in channel) {
for (queued in channel) {
// A pause whose key was dropped (approval resolved / resumed) is stale: skip it
// rather than narrate a paused state that no longer holds. removeIf-style check:
// claim the key so a re-enqueued pause with the same id is not also dropped.
if (queued.pauseKey != null && !lane.pendingPauses.remove(queued.pauseKey)) {
log.debug(
"skipping stale pause narration for session {} (key {})",
sessionId.value,
queued.pauseKey,
)
continue
}
val trigger = queued.trigger
runCatching { routerFacade.narrate(sessionId, trigger) }
.onFailure { e ->
if (e is CancellationException) throw e
@@ -148,12 +205,17 @@ class NarrationSubscriber(
}
}
}
return SessionLane(channel, used = 0)
return lane
}
companion object {
private const val DEFAULT_MAX_PER_RUN = 100
private const val MAX_PREVIEW_CHARS = 800
/** Supersession scope for pauses with no approval request (e.g. USER_REQUESTED). */
private const val SESSION_PAUSE_KEY = "__session_pause__"
private val log = LoggerFactory.getLogger(NarrationSubscriber::class.java)
private fun pauseKeyForRequest(requestId: String): String = "req:$requestId"
}
}
@@ -5,6 +5,7 @@ import com.correx.core.approvals.Tier
import com.correx.core.events.events.ClarificationAnswer
import com.correx.core.events.types.ApprovalRequestId
import com.correx.core.events.types.ClarificationRequestId
import com.correx.core.events.types.GrantId
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import com.correx.core.router.ChatMode
@@ -21,6 +22,11 @@ enum class ApprovalDecision {
enum class GrantScopeDto {
SESSION,
STAGE,
// Cross-session scopes (BACKLOG §grants). PROJECT auto-approves the bound tool for any session
// on the same workspace; GLOBAL for every session on this machine. Both are tool-bound and live
// in the cross-session grant ledger rather than a single session's stream.
PROJECT,
GLOBAL,
}
@Serializable
@@ -44,6 +50,10 @@ sealed class ClientMessage {
@Serializable
data class ListArtifacts(val sessionId: SessionId) : ClientMessage()
/** Operator request for the session workspace's file paths (the TUI `@` file-ref picker). */
@Serializable
data class ListFiles(val sessionId: SessionId) : ClientMessage()
/** Operator request for a session's derived metrics (replied to with a SessionStats). */
@Serializable
data class GetSessionStats(val sessionId: SessionId) : ClientMessage()
@@ -60,6 +70,14 @@ sealed class ClientMessage {
@Serializable
data class DiscardIdea(val ideaId: String) : ClientMessage()
/**
* Operator request to promote an idea into the bound session's curated project profile (added as
* a `conventions` entry in `<workspaceRoot>/.correx/project.toml`). Tombstones the idea like a
* discard; reply is a fresh IdeaList.
*/
@Serializable
data class PromoteIdea(val ideaId: String) : ClientMessage()
/** Operator request for the current editable config (replied to with a ConfigSnapshot). */
@Serializable
data object GetConfig : ClientMessage()
@@ -92,10 +110,20 @@ sealed class ClientMessage {
val permittedTiers: List<Tier>,
val reason: String,
val expiresAt: Instant? = null,
// Required for SESSION scope: binds this grant to a specific tool name.
// Required for SESSION/PROJECT/GLOBAL scope: binds this grant to a specific tool name.
// The projectId for a PROJECT grant is derived server-side from the session's bound
// workspace, never trusted from the client.
val toolName: String? = null,
) : ClientMessage()
/** Operator request to revoke a standing (PROJECT/GLOBAL) grant by id; reply is a fresh GrantList. */
@Serializable
data class RevokeGrant(val grantId: GrantId) : ClientMessage()
/** Operator request for the active cross-session grants (replied to with a GrantList). */
@Serializable
data object ListGrants : ClientMessage()
@Serializable
data class Ping(val timestamp: Long) : ClientMessage()
@@ -121,6 +121,23 @@ data class IdeaDto(
val capturedAtMs: Long,
)
/**
* One standing cross-session grant for the TUI grants viewer. [scope] is "PROJECT" or "GLOBAL";
* [tiers] are the tier names this grant auto-approves; [projectId] is the workspace path a PROJECT
* grant is bound to (null for GLOBAL); [expiresAtMs] is the epoch-ms expiry, or null if it never
* expires.
*/
@Serializable
data class GrantDto(
val grantId: String,
val scope: String,
val toolName: String?,
val projectId: String?,
val tiers: List<String>,
val reason: String,
val expiresAtMs: Long?,
)
internal fun RiskSummary.toDto(): RiskSummaryDto = RiskSummaryDto(
level = level.name,
factors = signals.map { signal ->
@@ -465,6 +465,19 @@ sealed interface ServerMessage {
override val sessionSequence: Long? = null,
) : ServerMessage, NonEventMessage
/**
* The session workspace's file paths (reply to ListFiles), for the TUI `@` file-ref picker.
* A snapshot directory walk, not event-derived, so cursors are null.
*/
@Serializable
@SerialName("file.list")
data class FileList(
val sessionId: SessionId,
val paths: List<String>,
override val sequence: Long? = null,
override val sessionSequence: Long? = null,
) : ServerMessage, NonEventMessage
/**
* The cross-session idea board (reply to ListIdeas / DiscardIdea). Not event-derived (a
* snapshot read folded from the whole event log), so cursors are null.
@@ -477,6 +490,18 @@ sealed interface ServerMessage {
override val sessionSequence: Long? = null,
) : ServerMessage, NonEventMessage
/**
* The active cross-session (PROJECT/GLOBAL) grants (reply to ListGrants / CreateGrant /
* RevokeGrant). Not event-derived (a snapshot folded from the grant ledger), so cursors are null.
*/
@Serializable
@SerialName("grant.list")
data class GrantList(
val grants: List<GrantDto>,
override val sequence: Long? = null,
override val sessionSequence: Long? = null,
) : ServerMessage, NonEventMessage
/**
* A session's derived metrics, returned in response to a GetSessionStats request. Not
* event-derived (it is a projection-replay snapshot), so cursors are null. [stats] is the same
@@ -0,0 +1,37 @@
package com.correx.apps.server.research
import com.correx.core.events.EventDispatcher
import com.correx.core.events.events.EgressHostsGrantedEvent
import com.correx.core.events.types.SessionId
/** Operator context recorded on the grant emitted by [approveSourceList]. */
internal const val BATCH_SOURCE_LIST_REASON = "batch source-list approval"
/**
* Batch fetch-approval at the source-list level (BACKLOG §D): the operator approves a whole research
* source list at once instead of clearing each `web_fetch` per-URL. Extracts the distinct hosts from
* [urls] (a source list's URLs, or its source_dossier source lines) and emits a single
* [EgressHostsGrantedEvent] for [sessionId]. That grant folds into the live per-session egress
* allowlist (commit ab7e4be) via [com.correx.core.sessions.projections.EgressAllowlistProjection], so
* subsequent fetches to those hosts auto-clear and no longer prompt.
*
* No-op when [urls] yields no parseable host: nothing to grant, so no event is emitted. Returns the
* granted host set (empty when nothing was emitted) for the caller to report back.
*/
suspend fun approveSourceList(
dispatcher: EventDispatcher,
sessionId: SessionId,
urls: List<String>,
): Set<String> {
val hosts = SourceListHosts.extract(urls)
if (hosts.isEmpty()) return emptySet()
dispatcher.emit(
payload = EgressHostsGrantedEvent(
sessionId = sessionId,
hosts = hosts,
reason = BATCH_SOURCE_LIST_REASON,
),
sessionId = sessionId,
)
return hosts
}
@@ -0,0 +1,34 @@
package com.correx.apps.server.research
import java.net.URI
/**
* Pure host extraction for batch source-list approval (BACKLOG §D). Given the URLs (or dossier
* source lines) of a research source list, returns the distinct, lower-cased hostnames the operator
* is approving egress to — the input set for a single [com.correx.core.events.events.EgressHostsGrantedEvent].
*
* Mirrors [com.correx.core.toolintent.rules.NetworkHostRule]'s URL parsing so the granted hosts line
* up with what the network gate later matches: absolute URLs only (`scheme://host`), parsed via
* [java.net.URI], host lower-cased, port dropped (URI.host excludes it). Entries that don't parse to
* a host — relative paths, free text, malformed URLs — are ignored rather than failing the batch.
*
* source_dossier entries start with the URL but may carry a trailing note
* ("https://example.com/x — explains Y"); the leading whitespace-delimited token is taken so those
* lines still yield their host.
*/
object SourceListHosts {
/**
* The distinct lower-cased hostnames drawn from [urls]. Deterministic; unparseable/relative
* entries are skipped. Duplicate hosts (and differing-case or differing-port variants of the
* same host) collapse to one.
*/
fun extract(urls: List<String>): Set<String> =
urls.mapNotNullTo(LinkedHashSet()) { hostOf(it) }
private fun hostOf(raw: String): String? {
val token = raw.trim().substringBefore(' ').substringBefore('\t')
if (!token.contains("://")) return null
return runCatching { URI(token).host?.lowercase() }.getOrNull()?.takeIf { it.isNotEmpty() }
}
}
@@ -2,6 +2,8 @@ package com.correx.apps.server.routes
import com.correx.apps.server.ServerModule
import com.correx.apps.server.metrics.MetricsInspectionService
import com.correx.apps.server.research.approveSourceList
import com.correx.core.events.EventDispatcher
import com.correx.apps.server.protocol.SessionConfigDto
import com.correx.apps.server.replay.ReplayInspectionService
import com.correx.apps.server.serialization.payloadDiscriminator
@@ -56,6 +58,12 @@ data class SessionStateResponse(val sessionId: String, val status: String, val c
@Serializable
data class StartSessionResponse(val sessionId: String)
@Serializable
data class ApproveSourcesRequest(val urls: List<String>)
@Serializable
data class ApproveSourcesResponse(val grantedHosts: List<String>)
private val log = LoggerFactory.getLogger("com.correx.apps.server.routes.SessionRoutes")
fun Route.sessionRoutes(module: ServerModule) {
@@ -79,6 +87,7 @@ fun Route.sessionRoutes(module: ServerModule) {
route("/{id}") {
getSessionRoute(module)
cancelSessionRoute(module)
approveSourcesRoute(module)
undoSessionRoute(module)
resumeSessionRoute(module)
getEventsRoute(module)
@@ -129,6 +138,24 @@ private fun Route.cancelSessionRoute(module: ServerModule) {
}
}
// Batch fetch-approval at the source-list level (BACKLOG §D): the operator approves a research
// source list once and egress is granted for all its hosts in one EgressHostsGrantedEvent, so the
// session's subsequent web_fetches to those hosts auto-clear instead of prompting per URL. Body
// carries the source list's URLs (or its source_dossier source lines).
private fun Route.approveSourcesRoute(module: ServerModule) {
post("/approve-sources") {
val id = call.parameters["id"]
?: return@post call.respond(HttpStatusCode.BadRequest, "Missing session id")
val body = call.receive<ApproveSourcesRequest>()
val granted = approveSourceList(
dispatcher = EventDispatcher(module.eventStore),
sessionId = TypeId(id),
urls = body.urls,
)
call.respond(HttpStatusCode.OK, ApproveSourcesResponse(grantedHosts = granted.sorted()))
}
}
private fun Route.undoSessionRoute(module: ServerModule) {
post("/undo") {
val id = call.parameters["id"]
@@ -0,0 +1,343 @@
package com.correx.apps.server.routes
import com.correx.apps.server.ServerModule
import com.correx.core.events.types.ProjectId
import com.correx.core.tasks.Commit
import com.correx.core.tasks.GitCommitReader
import com.correx.core.tasks.GitTaskSync
import com.correx.core.events.types.TaskId
import com.correx.core.events.types.TaskLinkType
import com.correx.core.events.types.TaskNoteAuthor
import com.correx.core.events.types.TaskTargetKind
import com.correx.core.tasks.Task
import com.correx.core.tasks.TaskContextAssembler
import com.correx.core.tasks.TaskGraph
import com.correx.core.tasks.TaskHistory
import com.correx.core.tasks.TaskMarkdown
import com.correx.core.tasks.TaskSearch
import com.correx.core.tasks.TaskService
import com.correx.core.tasks.TaskStatus
import com.correx.core.tasks.TaskTargetKinds
import com.correx.core.utils.TypeId
import io.ktor.http.ContentType
import io.ktor.http.HttpStatusCode
import io.ktor.server.request.receive
import io.ktor.server.response.respond
import io.ktor.server.response.respondText
import io.ktor.server.routing.Route
import io.ktor.server.routing.RoutingContext
import io.ktor.server.routing.delete
import io.ktor.server.routing.get
import io.ktor.server.routing.patch
import io.ktor.server.routing.post
import io.ktor.server.routing.route
import kotlinx.serialization.Serializable
@Serializable
data class TaskLinkDto(val targetId: String, val type: String, val targetKind: String)
@Serializable
data class TaskNoteDto(val author: String, val body: String, val at: String)
@Serializable
data class TaskResponse(
val id: String,
val key: String?,
val status: String,
val title: String?,
val goal: String?,
val acceptanceCriteria: List<String>,
val affectedPaths: List<String>,
val claimant: String?,
val links: List<TaskLinkDto>,
val notes: List<TaskNoteDto>,
val createdAt: String?,
val updatedAt: String?,
// Dependency-graph status for the board: ready = TODO with every dependency satisfied (workable
// now); blockedBy = the ids of unfinished tasks this one waits on (its unmet DEPENDS_ON/BLOCKS).
// Default to "not blocked / not ready" for the endpoints that don't resolve the graph.
val ready: Boolean = false,
val blockedBy: List<String> = emptyList(),
)
@Serializable
data class CreateTaskRequest(
val project: String,
val title: String,
val goal: String,
val acceptanceCriteria: List<String> = emptyList(),
val affectedPaths: List<String> = emptyList(),
// Skip the duplicate-title guard and create anyway.
val force: Boolean = false,
)
// Null fields are left untouched; a present list (even empty) replaces the stored one.
@Serializable
data class EditTaskRequest(
val title: String? = null,
val goal: String? = null,
val acceptanceCriteria: List<String>? = null,
val affectedPaths: List<String>? = null,
)
@Serializable
data class ClaimRequest(val claimant: String)
@Serializable
data class BlockRequest(val reason: String)
@Serializable
data class CancelRequest(val reason: String? = null)
@Serializable
data class LinkRequest(val targetId: String, val type: String = "RELATES_TO", val targetKind: String? = null)
@Serializable
data class NoteRequest(val body: String, val author: String? = null)
@Serializable
data class CommitDto(val sha: String = "", val message: String)
// When commits are supplied they are used verbatim; otherwise the server reads the last `limit`
// commits from the repo (when a GitCommitReader is wired).
@Serializable
data class SyncGitRequest(val commits: List<CommitDto> = emptyList(), val limit: Int = DEFAULT_SYNC_LIMIT)
private const val DEFAULT_SYNC_LIMIT = 20
/**
* Full REST surface for tasks. The write path mirrors the [TaskService] / `task_*` tools; both
* append to the event log, which stays the single source of truth (nothing is stored separately).
* A task's project is encoded in its id, so item routes need only the `{id}`. The list route takes
* optional `project` (cross-project board without it), `status`, and `q` (ranked text search);
* create requires `project` in the body. [GET /tasks/{id}/context] serves the agent bundle.
*/
fun Route.taskRoutes(module: ServerModule) {
val service = TaskService(module.eventStore)
val assembler = TaskContextAssembler(
service,
module.taskKnowledgeRetriever,
module.taskDocumentResolver,
module.taskArtifactResolver,
module.taskSessionResolver,
)
route("/tasks") {
taskCollectionRoutes(service)
taskSyncRoute(service, module.gitCommitReader)
route("/{id}") {
taskCrudRoutes(service, assembler)
taskTransitionRoutes(service)
taskGraphRoutes(service)
}
}
}
private fun Route.taskCollectionRoutes(service: TaskService) {
get {
// ?ready=true short-circuits to the dependency-aware workable set (TODO, no unmet blockers).
if (call.request.queryParameters["ready"]?.toBoolean() == true) {
val readyProject = call.request.queryParameters["project"]
val workable = service.ready(readyProject?.let { ProjectId(it) })
return@get call.respond(workable.map { it.toResponse().copy(ready = true) })
}
val statusRaw = call.request.queryParameters["status"]
val statusFilter = statusRaw?.let {
parseEnum<TaskStatus>(it) ?: return@get call.respond(HttpStatusCode.BadRequest, "Invalid status: $it")
}
// project is optional: scoped to one project when given, else the whole cross-project board.
val project = call.request.queryParameters["project"]
val base = (if (project != null) service.list(ProjectId(project)) else service.listAll())
.filter { statusFilter == null || it.state.status == statusFilter }
// q ranks by relevance; without it the board is most-recently-touched first.
val q = call.request.queryParameters["q"]
val ordered = if (q.isNullOrBlank()) {
base.sortedByDescending { it.state.updatedAt }
} else {
TaskSearch.search(base, q)
}
// Resolve ready/blockedBy against each task's FULL project board (not the filtered list — a
// status filter must not hide a finished blocker). One list() per distinct project, cached.
val boards = HashMap<ProjectId, List<Task>>()
fun enrich(t: Task): TaskResponse {
val pid = t.state.projectId ?: return t.toResponse()
val board = boards.getOrPut(pid) { service.list(pid) }
return t.toResponse().copy(
ready = TaskGraph.isReady(t, board),
blockedBy = TaskGraph.unmetBlockers(t, board).map { it.taskId.value },
)
}
call.respond(ordered.map(::enrich))
}
post {
val body = call.receive<CreateTaskRequest>()
val project = ProjectId(body.project)
// Duplicate-title guard (skippable with force): 409 with the look-alikes so the caller reuses one.
val duplicates = if (body.force) emptyList() else service.findDuplicates(project, body.title)
if (duplicates.isNotEmpty()) {
return@post call.respond(HttpStatusCode.Conflict, duplicates.map { it.toResponse() })
}
val task = service.createTask(
projectId = project,
title = body.title,
goal = body.goal,
acceptanceCriteria = body.acceptanceCriteria,
affectedPaths = body.affectedPaths,
)
call.respond(HttpStatusCode.Created, task.toResponse())
}
// Disposable Markdown projection of the board (whole board, or one project with ?project=).
get("/export") {
val project = call.request.queryParameters["project"]
val tasks = if (project != null) service.list(ProjectId(project)) else service.listAll()
call.respondText(TaskMarkdown.render(tasks), ContentType.Text.Plain)
}
}
// Git-driven status: a commit mention advances a task to IN_PROGRESS, a closing keyword
// ("fixes auth-12") advances it to DONE. Idempotent — wire this to a post-commit / post-merge hook
// or a CI step. Acts on commits in the body, or reads the repo's recent log when [reader] is set.
private fun Route.taskSyncRoute(service: TaskService, reader: GitCommitReader?) {
post("/sync-git") {
val body = runCatching { call.receive<SyncGitRequest>() }.getOrNull() ?: SyncGitRequest()
val commits = if (body.commits.isNotEmpty()) {
body.commits.map { Commit(it.sha, it.message) }
} else {
reader?.recent(body.limit) ?: emptyList()
}
call.respond(GitTaskSync(service).sync(commits))
}
}
private fun Route.taskCrudRoutes(service: TaskService, assembler: TaskContextAssembler) {
get {
val id = taskId() ?: return@get call.respond(HttpStatusCode.BadRequest, "Missing task id")
respondTask(service.getTask(id))
}
patch {
val id = taskId() ?: return@patch call.respond(HttpStatusCode.BadRequest, "Missing task id")
if (service.getTask(id) == null) return@patch call.respond(HttpStatusCode.NotFound, "Task not found")
val body = call.receive<EditTaskRequest>()
if (body.title != null || body.goal != null) service.edit(id, body.title, body.goal)
if (body.acceptanceCriteria != null) service.setAcceptanceCriteria(id, body.acceptanceCriteria)
if (body.affectedPaths != null) service.setAffectedPaths(id, body.affectedPaths)
respondTask(service.getTask(id))
}
delete {
val id = taskId() ?: return@delete call.respond(HttpStatusCode.BadRequest, "Missing task id")
if (service.delete(id)) call.respond(HttpStatusCode.NoContent)
else call.respond(HttpStatusCode.NotFound, "Task not found")
}
get("/context") {
val id = taskId() ?: return@get call.respond(HttpStatusCode.BadRequest, "Missing task id")
val bundle = assembler.assemble(id) ?: return@get call.respond(HttpStatusCode.NotFound, "Task not found")
call.respond(bundle)
}
// Lifecycle audit trail from the event log; visible even after a soft-delete. Empty history
// means the task id never existed.
get("/history") {
val id = taskId() ?: return@get call.respond(HttpStatusCode.BadRequest, "Missing task id")
val events = service.history(id)
if (events.isEmpty()) return@get call.respond(HttpStatusCode.NotFound, "Task not found")
call.respondText(TaskHistory.render(events), ContentType.Text.Plain)
}
// Unfinished tasks this one is waiting on (its dependencies + inbound blocks).
get("/blockers") {
val id = taskId() ?: return@get call.respond(HttpStatusCode.BadRequest, "Missing task id")
if (service.getTask(id) == null) return@get call.respond(HttpStatusCode.NotFound, "Task not found")
call.respond(service.blockers(id).map { it.toResponse() })
}
}
private fun Route.taskTransitionRoutes(service: TaskService) {
post("/claim") {
val id = taskId() ?: return@post call.respond(HttpStatusCode.BadRequest, "Missing task id")
respondTask(service.claim(id, call.receive<ClaimRequest>().claimant))
}
post("/release") {
val id = taskId() ?: return@post call.respond(HttpStatusCode.BadRequest, "Missing task id")
respondTask(service.release(id))
}
post("/block") {
val id = taskId() ?: return@post call.respond(HttpStatusCode.BadRequest, "Missing task id")
respondTask(service.block(id, call.receive<BlockRequest>().reason))
}
post("/unblock") {
val id = taskId() ?: return@post call.respond(HttpStatusCode.BadRequest, "Missing task id")
respondTask(service.unblock(id))
}
post("/submit-for-review") {
val id = taskId() ?: return@post call.respond(HttpStatusCode.BadRequest, "Missing task id")
respondTask(service.submitForReview(id))
}
post("/complete") {
val id = taskId() ?: return@post call.respond(HttpStatusCode.BadRequest, "Missing task id")
respondTask(service.complete(id))
}
post("/reopen") {
val id = taskId() ?: return@post call.respond(HttpStatusCode.BadRequest, "Missing task id")
respondTask(service.reopen(id))
}
post("/cancel") {
val id = taskId() ?: return@post call.respond(HttpStatusCode.BadRequest, "Missing task id")
val reason = runCatching { call.receive<CancelRequest>() }.getOrNull()?.reason
respondTask(service.cancel(id, reason))
}
}
private fun Route.taskGraphRoutes(service: TaskService) {
post("/links") {
val id = taskId() ?: return@post call.respond(HttpStatusCode.BadRequest, "Missing task id")
val body = call.receive<LinkRequest>()
val type = parseEnum<TaskLinkType>(body.type)
?: return@post call.respond(HttpStatusCode.BadRequest, "Invalid link type: ${body.type}")
val kind = when (val raw = body.targetKind) {
null -> TaskTargetKinds.infer(body.targetId)
else -> parseEnum<TaskTargetKind>(raw)
?: return@post call.respond(HttpStatusCode.BadRequest, "Invalid target kind: $raw")
}
respondTask(service.link(id, body.targetId, type, kind))
}
delete("/links") {
val id = taskId() ?: return@delete call.respond(HttpStatusCode.BadRequest, "Missing task id")
val targetId = call.request.queryParameters["targetId"]
?: return@delete call.respond(HttpStatusCode.BadRequest, "Missing 'targetId' query parameter")
val typeRaw = call.request.queryParameters["type"]
?: return@delete call.respond(HttpStatusCode.BadRequest, "Missing 'type' query parameter")
val type = parseEnum<TaskLinkType>(typeRaw)
?: return@delete call.respond(HttpStatusCode.BadRequest, "Invalid link type: $typeRaw")
respondTask(service.unlink(id, targetId, type))
}
post("/notes") {
val id = taskId() ?: return@post call.respond(HttpStatusCode.BadRequest, "Missing task id")
val body = call.receive<NoteRequest>()
val author = body.author?.let {
parseEnum<TaskNoteAuthor>(it) ?: return@post call.respond(HttpStatusCode.BadRequest, "Invalid author: $it")
} ?: TaskNoteAuthor.HUMAN
respondTask(service.addNote(id, author, body.body))
}
}
private fun RoutingContext.taskId(): TaskId? = call.parameters["id"]?.let { TypeId(it) }
private suspend fun RoutingContext.respondTask(task: Task?) {
if (task == null) call.respond(HttpStatusCode.NotFound, "Task not found")
else call.respond(task.toResponse())
}
private inline fun <reified T : Enum<T>> parseEnum(raw: String): T? =
enumValues<T>().firstOrNull { it.name.equals(raw, ignoreCase = true) }
private fun Task.toResponse(): TaskResponse = TaskResponse(
id = taskId.value,
key = state.key,
status = state.status.name,
title = state.title,
goal = state.goal,
acceptanceCriteria = state.acceptanceCriteria,
affectedPaths = state.affectedPaths,
claimant = state.claimant,
links = state.links.map { TaskLinkDto(it.targetId, it.type.name, it.targetKind.name) },
notes = state.notes.map { TaskNoteDto(it.author.name, it.body, it.at.toString()) },
createdAt = state.createdAt?.toString(),
updatedAt = state.updatedAt?.toString(),
)
@@ -0,0 +1,36 @@
package com.correx.apps.server.tasks
import com.correx.core.events.events.EventMetadata
import com.correx.core.events.events.NewEvent
import com.correx.core.events.events.SessionWorkingTaskEvent
import com.correx.core.events.stores.EventStore
import com.correx.core.events.types.EventId
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.TaskId
import com.correx.infrastructure.tools.task.SessionFactRecorder
import kotlinx.datetime.Clock
import java.util.UUID
/**
* Adapter for [SessionFactRecorder]: appends a [SessionWorkingTaskEvent] to the session's own stream
* when it claims (or re-scopes) a task, so [com.correx.core.toolintent.SessionContextProjection]
* folds it into the active task without scanning task streams.
*/
class EventStoreSessionFactRecorder(private val eventStore: EventStore) : SessionFactRecorder {
override suspend fun recordWorkingTask(sessionId: SessionId, taskId: TaskId, affectedPaths: List<String>) {
eventStore.append(
NewEvent(
metadata = EventMetadata(
eventId = EventId(UUID.randomUUID().toString()),
sessionId = sessionId,
timestamp = Clock.System.now(),
schemaVersion = 1,
causationId = null,
correlationId = null,
),
payload = SessionWorkingTaskEvent(sessionId, taskId, affectedPaths),
),
)
}
}
@@ -0,0 +1,21 @@
package com.correx.apps.server.tasks
import com.correx.core.events.stores.EventStore
import com.correx.core.events.types.SessionId
import com.correx.core.toolintent.SessionContextProjection
import com.correx.infrastructure.tools.task.SessionWrites
/**
* Adapter for the [SessionWrites] port: folds a session's events through
* [SessionContextProjection] and returns its writes (the recorded receipt.affectedEntities).
* Powers the cite-before-claim gate. Replay-safe (reads the log, never live state).
*/
class EventStoreSessionWrites(private val eventStore: EventStore) : SessionWrites {
override fun pathsWritten(sessionId: SessionId): Set<String> {
val projection = SessionContextProjection(sessionId)
return eventStore.read(sessionId)
.fold(projection.initial()) { acc, event -> projection.apply(acc, event) }
.writes
}
}
@@ -0,0 +1,53 @@
package com.correx.apps.server.tasks
import com.correx.core.artifactstore.ArtifactStore
import com.correx.core.events.events.ArtifactContentStoredEvent
import com.correx.core.events.events.ArtifactCreatedEvent
import com.correx.core.events.stores.EventStore
import com.correx.core.events.types.ArtifactId
import com.correx.core.tasks.ResolvedArtifact
import com.correx.core.tasks.TaskArtifactResolver
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
private const val EXCERPT_MAX = 280
/**
* Resolves ARTIFACT link targets for the task context bundle by scanning the event log for the
* artifact's lifecycle events: [ArtifactCreatedEvent]/[ArtifactContentStoredEvent] give the
* producing stage + session, and the content hash is dereferenced against the CAS [ArtifactStore]
* for an excerpt. Returns null when no events match the id — the link then stays a raw related link.
*/
class EventStoreTaskArtifactResolver(
private val eventStore: EventStore,
private val artifactStore: ArtifactStore,
private val excerptMax: Int = EXCERPT_MAX,
) : TaskArtifactResolver {
override suspend fun resolve(targetId: String): ResolvedArtifact? = withContext(Dispatchers.IO) {
val artifactId = ArtifactId(targetId)
var stage: String? = null
var sessionId: String? = null
var contentHash: ArtifactId? = null
eventStore.allEvents().forEach { stored ->
when (val p = stored.payload) {
is ArtifactCreatedEvent -> if (p.artifactId == artifactId) {
stage = p.stageId.value
sessionId = p.sessionId.value
}
is ArtifactContentStoredEvent -> if (p.artifactId == artifactId) {
stage = stage ?: p.stageId.value
sessionId = sessionId ?: p.sessionId.value
contentHash = p.contentHash
}
else -> Unit
}
}
if (stage == null && sessionId == null && contentHash == null) return@withContext null
val excerpt = contentHash?.let { hash ->
runCatching { artifactStore.get(hash) }.getOrNull()
?.toString(Charsets.UTF_8)?.trim()?.takeIf { it.isNotEmpty() }?.take(excerptMax)
}
ResolvedArtifact(stage = stage, sessionId = sessionId, excerpt = excerpt)
}
}
@@ -0,0 +1,91 @@
package com.correx.apps.server.tasks
import com.correx.core.tasks.ResolvedDocument
import com.correx.core.tasks.TaskDocumentResolver
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.nio.file.Files
import java.nio.file.Path
import kotlin.io.path.exists
import kotlin.io.path.isDirectory
import kotlin.io.path.isRegularFile
import kotlin.io.path.name
import kotlin.io.path.readText
private const val EXCERPT_MAX = 280
private val ADR_ID = Regex("(?i)^adr[-_ ]?0*(\\d+)$")
private val ADR_FILE = Regex("^adr-0*(\\d+)")
private val FRONTMATTER_NAME = Regex("(?m)^name:\\s*\"?(.+?)\"?\\s*$")
private val FRONTMATTER_DESC = Regex("(?m)^description:\\s*\"?(.+?)\"?\\s*$")
/**
* Resolves link targets to repo documents for the task context bundle:
* - ADR ids ("adr-7", "ADR-0007", "adr 12") → `docs/decisions/adr-<n>-*.md` (matched by number,
* padding-agnostic);
* - explicit "*.md" paths under the repo root.
* Title is the frontmatter `name:` or first `# ` heading; excerpt is the frontmatter
* `description:` or the first prose paragraph, capped. Returns null for anything else.
*/
class FileTaskDocumentResolver(
private val repoRoot: Path,
private val decisionsDir: Path = repoRoot.resolve("docs/decisions"),
) : TaskDocumentResolver {
override suspend fun resolve(targetId: String): ResolvedDocument? = withContext(Dispatchers.IO) {
resolveAdr(targetId.trim()) ?: resolveMarkdownPath(targetId.trim())
}
private fun resolveAdr(targetId: String): ResolvedDocument? {
val num = ADR_ID.matchEntire(targetId)?.groupValues?.get(1)?.toIntOrNull() ?: return null
if (!decisionsDir.isDirectory()) return null
val file = Files.list(decisionsDir).use { stream ->
stream.filter { it.isRegularFile() && it.name.endsWith(".md") }
.filter { adrNumber(it.name) == num }
.findFirst()
.orElse(null)
} ?: return null
return readDocument(file)
}
private fun resolveMarkdownPath(targetId: String): ResolvedDocument? {
if (!targetId.endsWith(".md")) return null
val candidate = repoRoot.resolve(targetId).normalize()
if (!candidate.startsWith(repoRoot) || !candidate.exists() || !candidate.isRegularFile()) return null
return readDocument(candidate)
}
private fun adrNumber(fileName: String): Int? =
ADR_FILE.find(fileName.lowercase())?.groupValues?.get(1)?.toIntOrNull()
private fun readDocument(file: Path): ResolvedDocument {
val text = runCatching { file.readText() }.getOrDefault("")
return ResolvedDocument(
title = extractTitle(text) ?: file.name,
path = repoRoot.relativize(file).toString(),
excerpt = extractExcerpt(text),
)
}
private fun extractTitle(text: String): String? {
FRONTMATTER_NAME.find(text)?.groupValues?.get(1)?.trim()?.takeIf { it.isNotBlank() }?.let { return it }
return text.lineSequence().firstOrNull { it.startsWith("# ") }?.removePrefix("# ")?.trim()
}
private fun extractExcerpt(text: String): String {
val desc = FRONTMATTER_DESC.find(text)?.groupValues?.get(1)?.trim()
return (desc ?: firstParagraph(text)).take(EXCERPT_MAX).trim()
}
private fun firstParagraph(text: String): String =
stripFrontmatter(text).lineSequence()
.map { it.trim() }
.firstOrNull { it.isNotEmpty() && !it.startsWith("#") && !it.startsWith("---") }
?: ""
private fun stripFrontmatter(text: String): String {
if (!text.startsWith("---")) return text
val lines = text.lines()
val closeIdx = lines.drop(1).indexOfFirst { it.trim() == "---" }
return if (closeIdx >= 0) lines.drop(closeIdx + 2).joinToString("\n") else text
}
}
@@ -0,0 +1,56 @@
package com.correx.apps.server.tasks
import com.correx.core.tasks.Commit
import com.correx.core.tasks.GitCommitReader
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.nio.file.Path
import java.util.concurrent.TimeUnit
private const val RS = "" // ASCII record separator, between commits
private const val US = "" // ASCII unit separator, between sha and body
private const val GIT_TIMEOUT_SEC = 10L
/**
* Reads recent commits by shelling `git log` in the repo. Best-effort: any failure (git missing,
* not a repo, timeout, non-zero exit) yields an empty list so the sync endpoint degrades to a no-op
* rather than erroring. Parsing is split into the testable top-level [parseGitLog].
*/
class GitCommandCommitReader(private val repoRoot: Path) : GitCommitReader {
override suspend fun recent(limit: Int): List<Commit> = withContext(Dispatchers.IO) {
runCatching { runGitLog(limit) }.getOrDefault(emptyList())
}
private fun runGitLog(limit: Int): List<Commit> {
val process = ProcessBuilder(
"git", "-C", repoRoot.toString(), "log", "-n", limit.toString(), "--format=%H$US%B$RS",
).redirectErrorStream(false).start()
val output = process.inputStream.bufferedReader().use { it.readText() }
val finished = process.waitFor(GIT_TIMEOUT_SEC, TimeUnit.SECONDS)
return if (finished && process.exitValue() == 0) {
parseGitLog(output)
} else {
if (!finished) process.destroy()
emptyList()
}
}
}
/**
* Parses `git log --format=%H<US>%B<RS>` output into commits: records split on the record
* separator, then each split once on the unit separator into sha + message. Top-level + internal
* so it is unit-testable without invoking git.
*/
internal fun parseGitLog(raw: String): List<Commit> =
raw.split(RS)
.map { it.trim() }
.filter { it.isNotEmpty() }
.mapNotNull { record ->
val idx = record.indexOf(US)
if (idx < 0) {
null
} else {
Commit(sha = record.substring(0, idx).trim(), message = record.substring(idx + 1).trim())
}
}
@@ -0,0 +1,33 @@
package com.correx.apps.server.tasks
import com.correx.core.events.stores.EventStore
import com.correx.core.sessions.SessionSummaryProjector
import com.correx.core.tasks.ResolvedSession
import com.correx.core.tasks.TaskSessionResolver
import com.correx.core.utils.TypeId
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* Resolves SESSION link targets for the task context bundle by projecting the session's events into
* a [com.correx.core.sessions.SessionSummary] (status / intent / workflow). Returns null when the
* session has no events — the link then stays a raw related link.
*/
class SessionSummaryTaskSessionResolver(
private val eventStore: EventStore,
) : TaskSessionResolver {
private val projector = SessionSummaryProjector()
override suspend fun resolve(targetId: String): ResolvedSession? = withContext(Dispatchers.IO) {
val sessionId = TypeId(targetId)
val events = eventStore.read(sessionId)
if (events.isEmpty()) return@withContext null
val summary = projector.project(sessionId, events)
ResolvedSession(
status = summary.status,
intent = summary.intent,
workflowId = summary.workflowId,
)
}
}
@@ -0,0 +1,46 @@
package com.correx.apps.server.workspace
import java.nio.file.Files
import java.nio.file.Path
import kotlin.io.path.isRegularFile
import kotlin.io.path.name
import kotlin.io.path.relativeTo
/**
* Lists workspace-relative file paths under a root for the TUI's `@` file-reference picker.
*
* Deliberately distinct from `RepoMapIndexer`: that one reads every file's *contents* to extract
* symbols and only covers source/markdown extensions (it feeds project memory + context). The `@`
* picker wants *all* file types, by path only, as cheaply as possible — so this never opens a file.
* It does share the indexer's directory-ignore convention (skip any path segment in [ignoreDirs])
* so both agree on what "the workspace" is. Results are sorted and capped to bound the reply.
*/
object WorkspaceFiles {
val DEFAULT_IGNORE_DIRS = setOf(
".git", "node_modules", "build", "target", ".gradle", "dist", ".idea", ".correx", ".venv",
)
const val DEFAULT_LIMIT = 2000
private const val MAX_DEPTH = 12
fun list(
root: Path,
ignoreDirs: Set<String> = DEFAULT_IGNORE_DIRS,
limit: Int = DEFAULT_LIMIT,
): List<String> {
if (!Files.isDirectory(root)) return emptyList()
val out = ArrayList<String>()
Files.walk(root, MAX_DEPTH).use { stream ->
val iter = stream.iterator()
while (iter.hasNext()) {
val path = iter.next()
if (!path.isRegularFile()) continue
val rel = path.relativeTo(root)
if (rel.any { it.name in ignoreDirs }) continue
out.add(rel.toString())
}
}
out.sort()
return out.take(limit)
}
}
@@ -13,12 +13,14 @@ import com.correx.apps.server.protocol.WorkflowDto
import com.correx.apps.server.protocol.StageToolDecl
import com.correx.apps.server.protocol.ToolDecl
import com.correx.apps.server.workspace.WorkspaceResolution
import com.correx.core.approvals.GRANT_LEDGER_SESSION_ID
import com.correx.core.approvals.GrantScope
import com.correx.core.approvals.Tier
import com.correx.core.events.events.ApprovalGrantCreatedEvent
import com.correx.core.events.events.ApprovalGrantExpiredEvent
import com.correx.core.events.events.ChatSessionStartedEvent
import com.correx.core.events.events.EventMetadata
import com.correx.core.events.events.IdeaDiscardedEvent
import com.correx.core.events.events.IdeaPromotedEvent
import com.correx.core.events.events.InitialIntentEvent
import com.correx.core.events.events.NewEvent
import com.correx.core.events.events.SessionWorkspaceBoundEvent
@@ -30,6 +32,9 @@ 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.ProjectProfileLoader
import com.correx.core.config.ProjectProfileWriter
import com.correx.core.config.withConvention
import com.correx.core.router.IdeaReader
import com.correx.core.utils.TypeId
import com.correx.infrastructure.inference.commons.ResourceProbe
@@ -231,8 +236,10 @@ class GlobalStreamHandler(private val module: ServerModule) {
.onFailure { log.error("cancel failed for session={}: {}", msg.sessionId.value, it.message, it) }
}
is ClientMessage.ListArtifacts -> sendFrame(queries.listArtifacts(msg.sessionId))
is ClientMessage.ListFiles -> sendFrame(queries.listFiles(msg.sessionId))
is ClientMessage.ListIdeas -> sendFrame(queries.listIdeas())
is ClientMessage.DiscardIdea -> handleDiscardIdea(msg, sendFrame)
is ClientMessage.PromoteIdea -> handlePromoteIdea(msg, sendFrame)
is ClientMessage.GetSessionStats -> sendFrame(queries.sessionStats(msg.sessionId))
is ClientMessage.GetHealthChecks -> sendFrame(queries.healthChecks())
is ClientMessage.GetConfig -> sendFrame(queries.configSnapshot(error = null, restartRequired = emptyList()))
@@ -241,6 +248,8 @@ class GlobalStreamHandler(private val module: ServerModule) {
is ClientMessage.ApprovalResponse -> handleApprovalResponse(msg, sendFrame)
is ClientMessage.ClarificationResponse -> handleClarificationResponse(msg)
is ClientMessage.CreateGrant -> handleCreateGrant(msg, sendFrame)
is ClientMessage.RevokeGrant -> handleRevokeGrant(msg, sendFrame)
is ClientMessage.ListGrants -> sendFrame(queries.listGrants())
is ClientMessage.ChatInput -> {
// The USER and ROUTER turns are emitted as ChatTurnEvents inside onUserInput
// and reach the client as chat.turn frames via streamGlobal — no direct
@@ -337,6 +346,57 @@ class GlobalStreamHandler(private val module: ServerModule) {
sendFrame(queries.listIdeas())
}
// Promote an idea into the bound session's curated project profile (a `conventions` entry in
// <workspaceRoot>/.correx/project.toml), then tombstone it like a discard so it leaves the board.
// No-ops (just refresh the board) when the idea is missing or its capturing session has no bound
// workspace — promotion needs a workspace to write the profile into.
private suspend fun handlePromoteIdea(
msg: ClientMessage.PromoteIdea,
sendFrame: suspend (ServerMessage) -> Unit,
) {
runCatching {
val reader = IdeaReader(module.eventStore)
val sessionId = reader.sessionOf(msg.ideaId) ?: return@runCatching
val text = reader.textOf(msg.ideaId) ?: return@runCatching
val workspaceRoot = workspaceRootOf(sessionId) ?: return@runCatching
withContext(Dispatchers.IO) {
val updated = ProjectProfileLoader.load(workspaceRoot).withConvention(text)
ProjectProfileWriter.write(workspaceRoot, updated)
}
module.eventStore.append(
NewEvent(
metadata = EventMetadata(
eventId = EventId(UUID.randomUUID().toString()),
sessionId = sessionId,
timestamp = Clock.System.now(),
schemaVersion = 1,
causationId = null,
correlationId = null,
),
payload = IdeaPromotedEvent(
ideaId = msg.ideaId,
sessionId = sessionId,
text = text,
timestampMs = System.currentTimeMillis(),
),
),
)
}.onFailure {
log.error("promoteIdea failed for ideaId={}: {}", msg.ideaId, it.message, it)
}
sendFrame(queries.listIdeas())
}
// The workspace a session was bound to, from its latest SessionWorkspaceBoundEvent (invariant #9),
// or null if the session never bound one.
private fun workspaceRootOf(sessionId: SessionId): String? =
module.eventStore.read(sessionId)
.mapNotNull { it.payload as? SessionWorkspaceBoundEvent }
.lastOrNull()
?.workspaceRoot
private fun errorResponse(message: String) = ServerMessage.ProtocolError(message)
private fun encodeError(message: String): Frame.Text =
@@ -346,17 +406,19 @@ class GlobalStreamHandler(private val module: ServerModule) {
msg: ClientMessage.CreateGrant,
sendFrame: suspend (ServerMessage) -> Unit,
) {
// Validate: tiers must be non-empty and bounded to T2 (server-side ceiling).
// T3/T4 are destructive/escalated tiers that must never be auto-approved via grants.
// No tier ceiling: the operator explicitly opted out of the prior T2 cap, so a grant may
// authorize any tier (including the destructive T3/T4). Every operator-creatable scope is
// still tool-bound (below) so a grant never becomes a blanket "approve everything".
if (msg.permittedTiers.isEmpty()) {
sendFrame(errorResponse("CreateGrant: permittedTiers must not be empty"))
return
}
val maxGrantableTier = Tier.T2
val overBroad = msg.permittedTiers.filter { it.level > maxGrantableTier.level }
if (overBroad.isNotEmpty()) {
sendFrame(errorResponse("CreateGrant: tiers ${overBroad.map { it.name }} exceed maximum grantable tier ${maxGrantableTier.name}"))
return
suspend fun requireToolName(scopeLabel: String): String? =
msg.toolName?.takeIf { it.isNotBlank() }
?: run {
sendFrame(errorResponse("CreateGrant: $scopeLabel scope requires toolName to prevent blanket approval"))
null
}
val scope = when (msg.scope) {
@@ -365,12 +427,7 @@ class GlobalStreamHandler(private val module: ServerModule) {
sendFrame(errorResponse("CreateGrant: SESSION scope must not include stageId"))
return
}
// Require toolName — blanket session grants (no operation scope) are rejected.
val toolName = msg.toolName?.takeIf { it.isNotBlank() } ?: run {
sendFrame(errorResponse("CreateGrant: SESSION scope requires toolName to prevent blanket approval"))
return
}
GrantScope.SESSION(toolName)
GrantScope.SESSION(requireToolName("SESSION") ?: return)
}
GrantScopeDto.STAGE -> {
val sid = msg.stageId ?: run {
@@ -379,11 +436,25 @@ class GlobalStreamHandler(private val module: ServerModule) {
}
GrantScope.STAGE(sid)
}
GrantScopeDto.PROJECT -> {
// projectId is derived from the session's bound workspace, never trusted from the client.
val projectId = queries.projectIdForSession(msg.sessionId) ?: run {
sendFrame(errorResponse("CreateGrant: PROJECT scope requires a session with a bound workspace"))
return
}
GrantScope.PROJECT(projectId, requireToolName("PROJECT") ?: return)
}
GrantScopeDto.GLOBAL -> GrantScope.GLOBAL(requireToolName("GLOBAL") ?: return)
}
// SESSION/STAGE grants live in the originating session's stream (they die with it); PROJECT
// and GLOBAL grants outlive any session, so they go to the shared cross-session grant ledger.
val ledgered = scope is GrantScope.PROJECT || scope is GrantScope.GLOBAL
val streamId = if (ledgered) GRANT_LEDGER_SESSION_ID else msg.sessionId
val event = NewEvent(
metadata = EventMetadata(
eventId = EventId(UUID.randomUUID().toString()),
sessionId = msg.sessionId,
sessionId = streamId,
timestamp = Clock.System.now(),
schemaVersion = 1,
causationId = null,
@@ -397,11 +468,44 @@ class GlobalStreamHandler(private val module: ServerModule) {
expiresAt = msg.expiresAt,
sessionId = msg.sessionId,
stageId = (scope as? GrantScope.STAGE)?.stageId,
projectId = null,
toolName = (scope as? GrantScope.SESSION)?.toolName,
projectId = (scope as? GrantScope.PROJECT)?.projectId,
toolName = scope.toolNameOrNull(),
),
)
module.eventStore.append(event)
// Echo the refreshed standing-grant list so the TUI can confirm a PROJECT/GLOBAL grant landed.
if (ledgered) sendFrame(queries.listGrants())
}
/**
* Revokes a standing (PROJECT/GLOBAL) grant by appending an [ApprovalGrantExpiredEvent] to the
* grant ledger — the reducer drops it, so the gate stops honouring it across all sessions while
* the create/revoke pair stays in the audit log. Replies with the refreshed grant list.
*/
private suspend fun handleRevokeGrant(
msg: ClientMessage.RevokeGrant,
sendFrame: suspend (ServerMessage) -> Unit,
) {
val event = NewEvent(
metadata = EventMetadata(
eventId = EventId(UUID.randomUUID().toString()),
sessionId = GRANT_LEDGER_SESSION_ID,
timestamp = Clock.System.now(),
schemaVersion = 1,
causationId = null,
correlationId = null,
),
payload = ApprovalGrantExpiredEvent(grantId = msg.grantId),
)
module.eventStore.append(event)
sendFrame(queries.listGrants())
}
private fun GrantScope.toolNameOrNull(): String? = when (this) {
is GrantScope.SESSION -> toolName
is GrantScope.PROJECT -> toolName
is GrantScope.GLOBAL -> toolName
is GrantScope.STAGE -> null
}
private suspend fun handleStartChatSession(
@@ -6,8 +6,14 @@ import com.correx.apps.server.health.HealthInspectionService
import com.correx.apps.server.metrics.MetricsInspectionService
import com.correx.apps.server.protocol.ArtifactSummaryDto
import com.correx.apps.server.protocol.ConfigFieldDto
import com.correx.apps.server.protocol.GrantDto
import com.correx.apps.server.protocol.IdeaDto
import com.correx.apps.server.protocol.ServerMessage
import com.correx.apps.server.workspace.WorkspaceFiles
import com.correx.core.approvals.GRANT_LEDGER_SESSION_ID
import com.correx.core.approvals.GrantScope
import com.correx.core.approvals.ProjectIdentity
import com.correx.core.events.types.ProjectId
import com.correx.core.router.IdeaReader
import com.correx.core.config.EditableConfig
import com.correx.core.events.events.ArtifactContentStoredEvent
@@ -15,11 +21,14 @@ import com.correx.core.events.events.ArtifactCreatedEvent
import com.correx.core.events.events.ArtifactValidatedEvent
import com.correx.core.events.events.ArtifactValidatingEvent
import com.correx.core.events.events.InferenceCompletedEvent
import com.correx.core.events.events.SessionWorkspaceBoundEvent
import com.correx.core.events.events.StoredEvent
import com.correx.core.events.types.ArtifactId
import com.correx.core.events.types.SessionId
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.datetime.Clock
import java.nio.file.Paths
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.jsonObject
@@ -62,6 +71,70 @@ class StreamQueries(private val module: ServerModule) {
return ServerMessage.HealthChecks(health = report)
}
/**
* The session workspace's file paths for the `@` picker. Resolves the bound workspace root from
* the recorded [SessionWorkspaceBoundEvent] (invariant #9) and lists it by path. Pure snapshot
* read — no events appended (replay-neutral). Empty when the session has no bound workspace.
*/
suspend fun listFiles(sessionId: SessionId): ServerMessage.FileList {
val paths = withContext(Dispatchers.IO) {
workspaceRootOf(sessionId)?.let { WorkspaceFiles.list(Paths.get(it)) } ?: emptyList()
}
return ServerMessage.FileList(sessionId = sessionId, paths = paths)
}
// The workspace a session was bound to, from its latest SessionWorkspaceBoundEvent, or null.
private fun workspaceRootOf(sessionId: SessionId): String? =
module.eventStore.read(sessionId)
.mapNotNull { it.payload as? SessionWorkspaceBoundEvent }
.lastOrNull()
?.workspaceRoot
/**
* The stable [ProjectId] a session's bound workspace resolves to (the same derivation the
* approval gate uses), or null when the session has no bound workspace. Used to scope a PROJECT
* grant to the right repository without trusting a client-supplied id.
*/
fun projectIdForSession(sessionId: SessionId): ProjectId? =
workspaceRootOf(sessionId)?.let { ProjectIdentity.of(it) }
/**
* The active cross-session grants as a snapshot frame, folded from the grant ledger via the
* approval reducer. Expired grants are dropped so the viewer only lists ones still in force.
* Pure read — no events appended (replay-neutral).
*/
fun listGrants(): ServerMessage.GrantList {
val now = Clock.System.now()
val grants = module.approvalRepository.getApprovalState(GRANT_LEDGER_SESSION_ID).grants.values
.filter { grant -> grant.expiresAt.let { it == null || it > now } }
.map { grant ->
GrantDto(
grantId = grant.id.value,
scope = grant.scope.kindName(),
toolName = grant.scope.toolNameOrNull(),
projectId = (grant.scope as? GrantScope.PROJECT)?.projectId?.value,
tiers = grant.permittedTiers.map { it.name }.sorted(),
reason = grant.reason,
expiresAtMs = grant.expiresAt?.toEpochMilliseconds(),
)
}
return ServerMessage.GrantList(grants = grants)
}
private fun GrantScope.kindName(): String = when (this) {
is GrantScope.SESSION -> "SESSION"
is GrantScope.STAGE -> "STAGE"
is GrantScope.PROJECT -> "PROJECT"
is GrantScope.GLOBAL -> "GLOBAL"
}
private fun GrantScope.toolNameOrNull(): String? = when (this) {
is GrantScope.SESSION -> toolName
is GrantScope.PROJECT -> toolName
is GrantScope.GLOBAL -> toolName
is GrantScope.STAGE -> null
}
/**
* Reads a session's events to assemble its artifact listing (creation order preserved),
* resolving each artifact's stored bytes via the CAS artifact store. Pure snapshot read —
@@ -0,0 +1,276 @@
package com.correx.apps.server
import com.correx.apps.server.memory.ArchitectContradictionChecker
import com.correx.apps.server.registry.ProviderRegistry
import com.correx.apps.server.registry.WorkflowRegistry
import com.correx.apps.server.registry.WorkflowSummary
import com.correx.apps.server.undo.SessionUndoService
import com.correx.core.artifactstore.ArtifactStore
import com.correx.core.context.builder.DefaultContextPackBuilder
import com.correx.core.context.compression.DefaultContextCompressor
import com.correx.core.events.EventDispatcher
import com.correx.core.events.events.ArtifactContentStoredEvent
import com.correx.core.events.events.ArtifactCreatedEvent
import com.correx.core.events.events.EventMetadata
import com.correx.core.events.events.EventPayload
import com.correx.core.events.events.NewEvent
import com.correx.core.events.events.PossibleContradictionFlaggedEvent
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.ProviderId
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import com.correx.core.inference.Embedder
import com.correx.core.inference.InferenceProjector
import com.correx.core.inference.InferenceProvider
import com.correx.core.inference.InferenceRepository
import com.correx.core.inference.ProviderHealth
import com.correx.core.kernel.orchestration.DefaultOrchestrationReducer
import com.correx.core.kernel.orchestration.DefaultSessionOrchestrator
import com.correx.core.kernel.orchestration.OrchestrationConfig
import com.correx.core.kernel.orchestration.OrchestrationProjector
import com.correx.core.kernel.orchestration.OrchestrationRepository
import com.correx.core.kernel.orchestration.OrchestratorEngines
import com.correx.core.kernel.orchestration.OrchestratorRepositories
import com.correx.core.kernel.retry.DefaultRetryCoordinator
import com.correx.core.risk.DefaultRiskAssessor
import com.correx.core.router.l3.L3Hit
import com.correx.core.router.l3.L3MemoryEntry
import com.correx.core.router.l3.L3MemoryStore
import com.correx.core.router.l3.L3Query
import com.correx.core.router.model.RouterConfig
import com.correx.core.sessions.DefaultSessionReducer
import com.correx.core.sessions.DefaultSessionRepository
import com.correx.core.sessions.SessionProjector
import com.correx.core.sessions.projections.replay.DefaultEventReplayer
import com.correx.core.transitions.graph.WorkflowGraph
import com.correx.core.transitions.resolution.DefaultTransitionResolver
import com.correx.core.validation.pipeline.ValidationPipeline
import com.correx.infrastructure.InfrastructureModule
import com.correx.infrastructure.inference.commons.UnavailableProbe
import com.correx.infrastructure.persistence.InMemoryEventStore
import com.correx.infrastructure.tools.FileEditConfig
import com.correx.infrastructure.tools.FileReadConfig
import com.correx.infrastructure.tools.FileWriteConfig
import com.correx.infrastructure.tools.ShellConfig
import com.correx.infrastructure.tools.ToolConfig
import kotlinx.coroutines.runBlocking
import kotlinx.datetime.Clock
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
import java.util.UUID
/** Always returns the same vector so the canned L3 store fully controls the scores. */
private class OnesEmbedder(override val dimension: Int = 8) : Embedder {
override suspend fun embed(text: String): FloatArray = FloatArray(dimension) { 1f }
}
/** Returns canned hits regardless of the query vector. */
private class CannedL3MemoryStore(private val hits: List<L3Hit>) : L3MemoryStore {
override suspend fun store(entry: L3MemoryEntry) = Unit
override suspend fun query(query: L3Query): List<L3Hit> = hits.sortedByDescending { it.score }.take(query.k)
override suspend fun existsByTurnIdPrefix(prefix: String): Boolean = hits.any { it.entry.turnId.startsWith(prefix) }
override suspend fun close() = Unit
}
/** Minimal in-memory CAS keyed by content hash. */
private class MapArtifactStore(private val bytesByHash: Map<String, ByteArray>) : ArtifactStore {
override suspend fun put(bytes: ByteArray): ArtifactId = ArtifactId("00".repeat(32))
override suspend fun get(id: ArtifactId): ByteArray? = bytesByHash[id.value]
override suspend fun flushBefore(commit: suspend () -> Unit) { commit() }
}
/**
* Exercises [ServerModule.handleArchitectArtifact]: the extraction/dispatch helper that backs the
* display-only architect contradiction subscription (BACKLOG §B-§4). A full subscription/integration
* test is not required — these assert the helper fetches the design artifact text and emits (or
* skips) the flag. The surrounding module is built with in-memory infra (no real model needed).
*/
class ArchitectContradictionHookTest {
private val session = SessionId("new-session")
private val priorSession = SessionId("prior-session")
private val architectStage = StageId("architect")
private val designArtifact = ArtifactId("design")
private val contentHash = ArtifactId("cafebabe")
private fun append(es: EventStore, payload: EventPayload, sid: SessionId = session) = runBlocking {
es.append(
NewEvent(
EventMetadata(EventId(UUID.randomUUID().toString()), sid, Clock.System.now(), 1, null, null),
payload,
),
)
}
private fun priorDecisionHit(text: String, score: Float) = L3Hit(
entry = L3MemoryEntry(
id = UUID.randomUUID().toString(),
sessionId = priorSession,
turnId = "project:/repo",
text = text,
vector = FloatArray(8) { 1f },
timestampMs = 0L,
),
score = score,
)
private fun flagsIn(es: EventStore): List<PossibleContradictionFlaggedEvent> =
es.read(session).map { it.payload }.filterIsInstance<PossibleContradictionFlaggedEvent>()
private fun buildModule(
eventStore: EventStore,
artifactStore: ArtifactStore,
checker: ArchitectContradictionChecker?,
tempDir: Path,
): ServerModule {
val provider = InfrastructureModule.createLlamaCppProvider(
modelId = "test-model",
modelPath = "/dev/null",
baseUrl = "http://127.0.0.1:1",
)
val providerRegistry = InfrastructureModule.createProviderRegistry(listOf(provider))
val inferenceRouter = com.correx.core.inference.DefaultInferenceRouter(
providerRegistry,
com.correx.infrastructure.inference.FirstAvailableRoutingStrategy(),
)
val toolConfig = ToolConfig(
shell = ShellConfig(enabled = false, allowedExecutables = emptySet(), workingDir = tempDir),
fileRead = FileReadConfig(enabled = false, allowedPaths = setOf(tempDir)),
fileWrite = FileWriteConfig(enabled = false, allowedPaths = setOf(tempDir), workingDir = tempDir),
fileEdit = FileEditConfig(enabled = false, allowedPaths = setOf(tempDir), workingDir = tempDir),
)
val toolRegistry = InfrastructureModule.createToolRegistry(toolConfig)
val toolExecutor = InfrastructureModule.createToolExecutor(
registry = toolRegistry,
eventDispatcher = EventDispatcher(eventStore),
workDir = tempDir,
artifactStore = null,
)
val engines = OrchestratorEngines(
transitionResolver = DefaultTransitionResolver { condition, ctx -> condition.evaluate(ctx) },
contextPackBuilder = DefaultContextPackBuilder(DefaultContextCompressor()),
inferenceRouter = inferenceRouter,
validationPipeline = ValidationPipeline(validators = emptyList()),
approvalEngine = com.correx.core.approvals.domain.DefaultApprovalEngine(),
riskAssessor = DefaultRiskAssessor(),
toolRegistry = toolRegistry,
toolExecutor = toolExecutor,
)
val repositories = OrchestratorRepositories(
eventStore = eventStore,
inferenceRepository = InferenceRepository(DefaultEventReplayer(eventStore, InferenceProjector())),
orchestrationRepository = OrchestrationRepository(
DefaultEventReplayer(eventStore, OrchestrationProjector(DefaultOrchestrationReducer())),
),
sessionRepository = DefaultSessionRepository(
DefaultEventReplayer(eventStore, SessionProjector(DefaultSessionReducer())),
),
artifactRepository = InfrastructureModule.createArtifactRepository(eventStore),
approvalRepository = InfrastructureModule.createApprovalRepository(eventStore),
)
val orchestrator = DefaultSessionOrchestrator(
repositories = repositories,
engines = engines,
retryCoordinator = DefaultRetryCoordinator(eventStore),
artifactStore = artifactStore,
tokenizer = provider.tokenizer,
decisionJournalRepository = InfrastructureModule.createDecisionJournalRepository(eventStore),
)
val routerFacade = InfrastructureModule.createRouterFacade(
eventStore = eventStore,
inferenceRouter = inferenceRouter,
config = RouterConfig(),
tokenizer = provider.tokenizer,
)
val noopProviderRegistry = object : ProviderRegistry {
override fun listAll() = emptyList<InferenceProvider>()
override suspend fun healthCheckAll() = emptyMap<ProviderId, ProviderHealth>()
}
val noopWorkflowRegistry = object : WorkflowRegistry {
override fun listAll(): List<WorkflowSummary> = emptyList()
override fun find(workflowId: String): WorkflowGraph? = null
}
return ServerModule(
orchestrator = orchestrator,
eventStore = eventStore,
artifactStore = artifactStore,
sessionRepository = repositories.sessionRepository,
workflowRegistry = noopWorkflowRegistry,
providerRegistry = noopProviderRegistry,
defaultOrchestrationConfig = OrchestrationConfig(sandboxRoot = tempDir),
routerFacade = routerFacade,
orchestrationRepository = repositories.orchestrationRepository,
approvalRepository = repositories.approvalRepository,
toolRegistry = toolRegistry,
sessionUndoService = SessionUndoService(eventStore, artifactStore, bootRoots = setOf(tempDir)),
resourceProbe = UnavailableProbe,
architectContradictionChecker = checker,
)
}
@Test
fun `emits the flag when the checker surfaces a near prior decision`(@TempDir tempDir: Path) = runBlocking {
val es = InMemoryEventStore()
val designJson = """{"approach":"Use Postgres for the event store.","components":["db.kt"]}"""
val artifacts = MapArtifactStore(mapOf(contentHash.value to designJson.toByteArray()))
// ArtifactContentStored precedes ArtifactCreated for the same artifactId (inference-time).
append(es, ArtifactContentStoredEvent(designArtifact, contentHash, session, architectStage))
val created = ArtifactCreatedEvent(designArtifact, session, architectStage, schemaVersion = 1)
append(es, created)
val checker = ArchitectContradictionChecker(
OnesEmbedder(),
CannedL3MemoryStore(listOf(priorDecisionHit("Decided to use SQLite for the event store.", 0.9f))),
)
val flag = buildModule(es, artifacts, checker, tempDir).handleArchitectArtifact(checker, created)
assertNotNull(flag, "expected a contradiction flag")
assertEquals(session, flag!!.sessionId)
assertEquals(architectStage, flag.stageId)
// The design schema's `approach` field is preferred as the decision text.
assertEquals("Use Postgres for the event store.", flag.decisionSummary)
assertEquals("Decided to use SQLite for the event store.", flag.related.single().summary)
// The flag is appended to the session stream (display-only, but recorded for the operator).
assertEquals(1, flagsIn(es).size)
}
@Test
fun `returns null and appends nothing when the checker finds no related decision`(
@TempDir tempDir: Path,
) = runBlocking {
val es = InMemoryEventStore()
val designJson = """{"approach":"Use Postgres for the event store.","components":["db.kt"]}"""
val artifacts = MapArtifactStore(mapOf(contentHash.value to designJson.toByteArray()))
append(es, ArtifactContentStoredEvent(designArtifact, contentHash, session, architectStage))
val created = ArtifactCreatedEvent(designArtifact, session, architectStage, schemaVersion = 1)
append(es, created)
val checker = ArchitectContradictionChecker(OnesEmbedder(), CannedL3MemoryStore(emptyList()))
val flag = buildModule(es, artifacts, checker, tempDir).handleArchitectArtifact(checker, created)
assertNull(flag)
assertEquals(0, flagsIn(es).size)
}
@Test
fun `returns null when no content hash was recorded for the artifact`(@TempDir tempDir: Path) = runBlocking {
val es = InMemoryEventStore()
val created = ArtifactCreatedEvent(designArtifact, session, architectStage, schemaVersion = 1)
append(es, created)
val checker = ArchitectContradictionChecker(
OnesEmbedder(),
CannedL3MemoryStore(listOf(priorDecisionHit("Decided to use SQLite.", 0.9f))),
)
val flag = buildModule(es, MapArtifactStore(emptyMap()), checker, tempDir)
.handleArchitectArtifact(checker, created)
assertNull(flag)
}
}
@@ -0,0 +1,107 @@
package com.correx.apps.server.memory
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import com.correx.core.inference.Embedder
import com.correx.core.router.l3.L3Hit
import com.correx.core.router.l3.L3MemoryEntry
import com.correx.core.router.l3.L3MemoryStore
import com.correx.core.router.l3.L3Query
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.util.UUID
private class ContradictionOnesEmbedder(override val dimension: Int = 8) : Embedder {
override suspend fun embed(text: String): FloatArray = FloatArray(dimension) { 1f }
}
/** Returns canned hits regardless of the query vector, so scores can be controlled precisely. */
private class CannedL3MemoryStore(private val hits: List<L3Hit>) : L3MemoryStore {
override suspend fun store(entry: L3MemoryEntry) = Unit
override suspend fun query(query: L3Query): List<L3Hit> = hits.sortedByDescending { it.score }.take(query.k)
override suspend fun existsByTurnIdPrefix(prefix: String): Boolean = hits.any { it.entry.turnId.startsWith(prefix) }
override suspend fun close() = Unit
}
class ArchitectContradictionCheckerTest {
private val newSession = SessionId("new-session")
private val priorSession = SessionId("prior-session")
private val stageId = StageId("architect")
private fun hit(text: String, score: Float, turnId: String, sessionId: SessionId = priorSession) =
L3Hit(
entry = L3MemoryEntry(
id = UUID.randomUUID().toString(),
sessionId = sessionId,
turnId = turnId,
text = text,
vector = FloatArray(8) { 1f },
timestampMs = 0L,
),
score = score,
)
@Test
fun `flags a related prior decision above threshold`() = runBlocking {
val store = CannedL3MemoryStore(
listOf(hit("Decided to use SQLite for the event store.", score = 0.9f, turnId = "project:/repo")),
)
val checker = ArchitectContradictionChecker(ContradictionOnesEmbedder(), store)
val flag = checker.check(newSession, stageId, "Use Postgres for the event store.")
assertTrue(flag != null, "expected a flag")
assertEquals(newSession, flag!!.sessionId)
assertEquals(stageId, flag.stageId)
assertEquals("Use Postgres for the event store.", flag.decisionSummary)
assertEquals(1, flag.related.size)
val related = flag.related.single()
assertEquals("Decided to use SQLite for the event store.", related.summary)
assertEquals("project:/repo", related.source)
assertEquals(0.9, related.score, 1e-6)
}
@Test
fun `returns null when there are no hits`() = runBlocking {
val checker = ArchitectContradictionChecker(ContradictionOnesEmbedder(), CannedL3MemoryStore(emptyList()))
assertNull(checker.check(newSession, stageId, "Use Postgres for the event store."))
}
@Test
fun `returns null when all hits are below threshold`() = runBlocking {
val store = CannedL3MemoryStore(
listOf(hit("Loosely related prior note.", score = 0.5f, turnId = "project:/repo")),
)
val checker = ArchitectContradictionChecker(ContradictionOnesEmbedder(), store, scoreThreshold = 0.75)
assertNull(checker.check(newSession, stageId, "Use Postgres for the event store."))
}
@Test
fun `filters out hits outside the decision namespace`() = runBlocking {
val store = CannedL3MemoryStore(
listOf(
// Above threshold but a repo-map entry, not a decision — must be excluded.
hit("Foo.kt: ClassA, funcB", score = 0.95f, turnId = "repomap:/repo:abc"),
),
)
val checker = ArchitectContradictionChecker(ContradictionOnesEmbedder(), store)
assertNull(checker.check(newSession, stageId, "Use Postgres for the event store."))
}
@Test
fun `filters out the architect's own in-flight session`() = runBlocking {
val store = CannedL3MemoryStore(
listOf(hit("Same-session decision.", score = 0.95f, turnId = "project:/repo", sessionId = newSession)),
)
val checker = ArchitectContradictionChecker(ContradictionOnesEmbedder(), store)
assertNull(checker.check(newSession, stageId, "Use Postgres for the event store."))
}
}
@@ -12,6 +12,7 @@ import com.correx.core.events.types.SessionId
import com.correx.infrastructure.persistence.InMemoryEventStore
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.nio.file.Path
@@ -121,4 +122,36 @@ class ProjectMemoryServiceReuseTest {
"entries should be embedded with stateKey tag",
)
}
@Test
fun `repoRoot prefix does not collide across roots — repo vs repo2`(): Unit = runBlocking {
val es = InMemoryEventStore()
val l3 = InMemoryL3MemoryStore()
val probe = FakeWorkspaceStateProbe(WorkspaceState("git:hash1", "git", "main", false))
val embedder = ConstantEmbedderReuse()
// Index /repo and /repo2 into the same shared L3 store with the same stateKey.
// The trailing-':' delimiter on the repomap: tag must keep their entries from bleeding
// across roots: "/repo" must NOT match tags written for "/repo2".
val repoEntries = listOf(RepoMapEntry(path = "src/Repo.kt", score = 1.0, symbols = listOf("RepoClass")))
val repo2Entries = listOf(RepoMapEntry(path = "src/Repo2.kt", score = 1.0, symbols = listOf("Repo2Class")))
service(es, l3, CountingIndexer(repoEntries), probe, root = "/repo")
.indexAndRecord(SessionId("session-collide-repo"), "/repo")
service(es, l3, CountingIndexer(repo2Entries), probe, root = "/repo2")
.indexAndRecord(SessionId("session-collide-repo2"), "/repo2")
// existsByTurnIdPrefix with the delimiter must not match the other root.
assertTrue(l3.existsByTurnIdPrefix("repomap:/repo:"), "tag for /repo should exist")
assertTrue(l3.existsByTurnIdPrefix("repomap:/repo2:"), "tag for /repo2 should exist")
// The retriever scoped to /repo must return only /repo's entry, never /repo2's.
val hits = L3RepoKnowledgeRetriever(embedder = embedder, l3MemoryStore = l3, repoRoot = "/repo")
.retrieve(SessionId("session-collide-query"), "anything", k = 10)
assertTrue(hits.isNotEmpty(), "retriever for /repo should find /repo entries")
val text = hits.joinToString(" ") { it.text }
assertTrue(text.contains("src/Repo.kt"), "should contain /repo entry; got: $text")
assertFalse(text.contains("src/Repo2.kt"), "must NOT contain /repo2 entry (prefix collision); got: $text")
}
}
@@ -75,6 +75,42 @@ class RepoMapIndexerTest {
assertFalse(entry.symbols.contains("help"), "indented inner method must not be matched")
}
@Test
fun `extracts C# (Godot Mono) top-level type symbols`(@TempDir root: Path) {
root.resolve("Player.cs").writeText(
"""
using Godot;
namespace Game;
public sealed partial class Player : CharacterBody2D
{
private int _health = 100;
public void TakeDamage(int amount)
{
_health -= amount;
}
}
internal interface IDamageable
{
void TakeDamage(int amount);
}
public enum State { Idle, Running }
""".trimIndent(),
)
val entry = RepoMapIndexer().index(root).single()
assertEquals("Player.cs", entry.path)
assertTrue(
entry.symbols.containsAll(listOf("Player", "IDamageable", "State")),
"missing symbols in: ${entry.symbols}",
)
assertFalse(entry.symbols.contains("TakeDamage"), "method/field bodies must not be matched")
}
@Test
fun `extracts markdown headings as symbols`(@TempDir root: Path) {
root.resolve("design.md").writeText(
@@ -1,18 +1,23 @@
package com.correx.apps.server.narration
import com.correx.core.approvals.ApprovalOutcome
import com.correx.core.approvals.ApprovalStatus
import com.correx.core.approvals.Tier
import com.correx.core.events.events.ApprovalDecisionResolvedEvent
import com.correx.core.events.events.ApprovalRequestedEvent
import com.correx.core.events.events.EventMetadata
import com.correx.core.events.events.EventPayload
import com.correx.core.events.events.ExecutionPlanRejectedEvent
import com.correx.core.events.events.NewEvent
import com.correx.core.events.events.OrchestrationPausedEvent
import com.correx.core.events.events.OrchestrationResumedEvent
import com.correx.core.events.events.StageCompletedEvent
import com.correx.core.events.events.StageFailedEvent
import com.correx.core.events.events.StoredEvent
import com.correx.core.events.events.WorkflowCompletedEvent
import com.correx.core.events.events.WorkflowStartedEvent
import com.correx.core.events.stores.EventStore
import com.correx.core.events.types.ApprovalDecisionId
import com.correx.core.events.types.ApprovalRequestId
import com.correx.core.events.types.EventId
import com.correx.core.events.types.SessionId
@@ -24,10 +29,12 @@ import com.correx.core.router.ChatMode
import com.correx.core.router.RouterFacade
import com.correx.core.router.model.NarrationTrigger
import com.correx.core.router.model.RouterResponse
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.runBlocking
@@ -231,4 +238,133 @@ class NarrationSubscriberTest {
"instruction must include the source",
)
}
/**
* Router facade that blocks the lane worker on the first narration until [release] completes,
* letting the test enqueue further events (and resolve the pause) while the pause narration is
* still pending in the channel.
*/
private class GatingRouterFacade : RouterFacade {
val calls = CopyOnWriteArrayList<NarrationTrigger>()
val release = CompletableDeferred<Unit>()
// Signals that the worker has pulled the first (warmup) trigger and is now blocked,
// so any narrations enqueued afterwards are guaranteed to still be queued.
val blocked = CompletableDeferred<Unit>()
private var first = true
override suspend fun onUserInput(sessionId: SessionId, input: String, mode: ChatMode): RouterResponse =
error("unused in narration tests")
override suspend fun narrate(sessionId: SessionId, trigger: NarrationTrigger) {
if (first) {
first = false
blocked.complete(Unit)
release.await()
}
calls.add(trigger)
}
}
private fun approvalRequested(requestId: String): ApprovalRequestedEvent = ApprovalRequestedEvent(
requestId = ApprovalRequestId(requestId),
tier = Tier.T2,
validationReportId = ValidationReportId("vr-1"),
riskSummaryId = null,
sessionId = sessionId,
stageId = StageId("write_script"),
projectId = null,
toolName = "file_write",
preview = "--- a/x.sh\n+++ b/x.sh",
)
private fun approvalResolved(requestId: String): ApprovalDecisionResolvedEvent = ApprovalDecisionResolvedEvent(
decisionId = ApprovalDecisionId("dec-1"),
requestId = ApprovalRequestId(requestId),
outcome = ApprovalOutcome.APPROVED,
status = ApprovalStatus.COMPLETED,
tier = Tier.T2,
resolutionTimestamp = timestamp,
reason = null,
)
@Test
fun `resolved approval drops the still-queued pause narration`(): Unit = runBlocking {
val facade = GatingRouterFacade()
NarrationSubscriber(fakeStore, facade, scope).start()
while (liveFlow.subscriptionCount.value == 0) yield()
liveFlow.emit(storedEvent(WorkflowStartedEvent(sessionId, "wf", StageId("s0")), seq = 1L))
// Warmup narration the worker will block on, keeping later narrations queued.
liveFlow.emit(storedEvent(StageCompletedEvent(sessionId, StageId("a"), TransitionId("t1")), seq = 2L))
// Wait until the worker is parked on the warmup before enqueuing the pause, so the pause
// is provably still queued (not yet delivered) when its approval resolves.
withTimeout(2_000L) { facade.blocked.await() }
// Pause is enqueued, then its approval resolves before the worker can drain it.
liveFlow.emit(storedEvent(approvalRequested("req-1"), seq = 3L))
liveFlow.emit(storedEvent(approvalResolved("req-1"), seq = 4L))
// A current, unrelated narration arrives after resolution and must still surface.
liveFlow.emit(storedEvent(StageCompletedEvent(sessionId, StageId("b"), TransitionId("t2")), seq = 5L))
// Let the (single, FIFO) collector drain its buffer so the resolve is handled pre-release.
delay(100L)
// Release the worker; it drains the warmup, skips the stale pause, narrates the second stage.
facade.release.complete(Unit)
withTimeout(2_000L) { while (facade.calls.size < 2) yield() }
delay(100L) // allow a stray (stale) pause narration to arrive if the drop failed
assertEquals(listOf("stage_completed", "stage_completed"), facade.calls.map { it.kind })
assertTrue(facade.calls.none { it.kind == "paused" }, "stale pause narration must be dropped")
}
@Test
fun `unrelated approval resolution leaves the pending pause intact`(): Unit = runBlocking {
val facade = GatingRouterFacade()
NarrationSubscriber(fakeStore, facade, scope).start()
while (liveFlow.subscriptionCount.value == 0) yield()
liveFlow.emit(storedEvent(WorkflowStartedEvent(sessionId, "wf", StageId("s0")), seq = 1L))
liveFlow.emit(storedEvent(StageCompletedEvent(sessionId, StageId("a"), TransitionId("t1")), seq = 2L))
withTimeout(2_000L) { facade.blocked.await() }
// Pause for req-1, but a *different* request (req-2) resolves: req-1's pause must survive.
liveFlow.emit(storedEvent(approvalRequested("req-1"), seq = 3L))
liveFlow.emit(storedEvent(approvalResolved("req-2"), seq = 4L))
delay(100L)
facade.release.complete(Unit)
withTimeout(2_000L) { while (facade.calls.size < 2) yield() }
assertEquals(listOf("stage_completed", "paused"), facade.calls.map { it.kind })
}
@Test
fun `resume drops every queued pause narration for the session`(): Unit = runBlocking {
val facade = GatingRouterFacade()
NarrationSubscriber(fakeStore, facade, scope).start()
while (liveFlow.subscriptionCount.value == 0) yield()
liveFlow.emit(storedEvent(WorkflowStartedEvent(sessionId, "wf", StageId("s0")), seq = 1L))
liveFlow.emit(storedEvent(StageCompletedEvent(sessionId, StageId("a"), TransitionId("t1")), seq = 2L))
withTimeout(2_000L) { facade.blocked.await() }
// A non-approval pause (session-scoped) plus an approval pause, both superseded by resume.
liveFlow.emit(
storedEvent(OrchestrationPausedEvent(sessionId, StageId("a"), "USER_REQUESTED"), seq = 3L),
)
liveFlow.emit(storedEvent(approvalRequested("req-1"), seq = 4L))
liveFlow.emit(storedEvent(OrchestrationResumedEvent(sessionId, StageId("a")), seq = 5L))
liveFlow.emit(storedEvent(StageCompletedEvent(sessionId, StageId("b"), TransitionId("t2")), seq = 6L))
delay(100L)
facade.release.complete(Unit)
withTimeout(2_000L) { while (facade.calls.size < 2) yield() }
delay(100L)
assertEquals(listOf("stage_completed", "stage_completed"), facade.calls.map { it.kind })
assertTrue(facade.calls.none { it.kind == "paused" }, "resume must drop all queued pauses")
}
}
@@ -316,6 +316,32 @@ class ServerMessageSerializationTest {
assertEquals("i1", (decoded as ClientMessage.DiscardIdea).ideaId)
}
@Test
fun `PromoteIdea decodes from the client wire format`() {
val wire = """{"type":"com.correx.apps.server.protocol.ClientMessage.PromoteIdea","ideaId":"i1"}"""
val decoded = ProtocolSerializer.decodeClientMessage(wire)
assert(decoded is ClientMessage.PromoteIdea) { "expected PromoteIdea, got $decoded" }
assertEquals("i1", (decoded as ClientMessage.PromoteIdea).ideaId)
}
@Test
fun `FileList encodes type file_list and paths`() {
val msg = ServerMessage.FileList(sessionId = SessionId("s1"), paths = listOf("README.md", "src/Main.kt"))
val jsonStr = ProtocolSerializer.encodeServerMessage(msg)
assert(jsonStr.contains("\"type\":\"file.list\"")) { "expected type=file.list" }
val decoded = json.decodeFromString<ServerMessage.FileList>(jsonStr)
assertEquals(listOf("README.md", "src/Main.kt"), decoded.paths)
}
@Test
fun `ListFiles decodes from the client wire format`() {
val wire = """{"type":"com.correx.apps.server.protocol.ClientMessage.ListFiles","sessionId":"s1"}"""
val decoded = ProtocolSerializer.decodeClientMessage(wire)
assert(decoded is ClientMessage.ListFiles) { "expected ListFiles, got $decoded" }
assertEquals("s1", (decoded as ClientMessage.ListFiles).sessionId.value)
}
@Test
fun `ClarificationResponse decodes from the client wire format`() {
val wire = """
@@ -0,0 +1,63 @@
package com.correx.apps.server.research
import com.correx.core.events.EventDispatcher
import com.correx.core.events.events.EgressHostsGrantedEvent
import com.correx.core.events.types.SessionId
import com.correx.infrastructure.persistence.InMemoryEventStore
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
class SourceListApprovalTest {
private val sessionId = SessionId("s1")
private fun grants(store: InMemoryEventStore, session: SessionId = sessionId): List<EgressHostsGrantedEvent> =
store.read(session).map { it.payload }.filterIsInstance<EgressHostsGrantedEvent>()
@Test
fun `emits exactly one EgressHostsGrantedEvent with the extracted hosts for the session`() = runTest {
val store = InMemoryEventStore()
val granted = approveSourceList(
dispatcher = EventDispatcher(store),
sessionId = sessionId,
urls = listOf(
"https://example.com/a",
"https://example.com/b",
"https://docs.kotlinlang.org/spec",
),
)
assertEquals(setOf("example.com", "docs.kotlinlang.org"), granted)
val emitted = grants(store)
assertEquals(1, emitted.size)
val event = emitted.single()
assertEquals(sessionId, event.sessionId)
assertEquals(setOf("example.com", "docs.kotlinlang.org"), event.hosts)
assertEquals(BATCH_SOURCE_LIST_REASON, event.reason)
}
@Test
fun `grant is recorded under the right session`() = runTest {
val store = InMemoryEventStore()
approveSourceList(EventDispatcher(store), sessionId, listOf("https://only-mine.com/x"))
assertEquals(1, grants(store).size)
assertTrue(grants(store, SessionId("other")).isEmpty())
}
@Test
fun `no parseable host emits no event`() = runTest {
val store = InMemoryEventStore()
val granted = approveSourceList(
dispatcher = EventDispatcher(store),
sessionId = sessionId,
urls = listOf("/relative", "just text", ""),
)
assertTrue(granted.isEmpty())
assertTrue(grants(store).isEmpty())
}
}
@@ -0,0 +1,69 @@
package com.correx.apps.server.research
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
class SourceListHostsTest {
@Test
fun `distinct hosts are extracted from multiple urls`() {
val hosts = SourceListHosts.extract(
listOf(
"https://example.com/a",
"https://docs.rust-lang.org/book",
"http://api.github.com/repos",
),
)
assertEquals(setOf("example.com", "docs.rust-lang.org", "api.github.com"), hosts)
}
@Test
fun `duplicate hosts collapse`() {
val hosts = SourceListHosts.extract(
listOf(
"https://example.com/one",
"https://example.com/two",
"http://example.com/three",
),
)
assertEquals(setOf("example.com"), hosts)
}
@Test
fun `ports are stripped`() {
val hosts = SourceListHosts.extract(listOf("https://example.com:8443/path"))
assertEquals(setOf("example.com"), hosts)
}
@Test
fun `host case is normalised to lower case`() {
val hosts = SourceListHosts.extract(
listOf("https://Example.COM/a", "https://EXAMPLE.com/b"),
)
assertEquals(setOf("example.com"), hosts)
}
@Test
fun `unparseable and relative entries are ignored`() {
val hosts = SourceListHosts.extract(
listOf(
"/local/path",
"not a url at all",
"ftp ://broken",
"",
"https://kept.com/x",
),
)
assertEquals(setOf("kept.com"), hosts)
}
@Test
fun `dossier source lines yield their leading url host`() {
val hosts = SourceListHosts.extract(
listOf("https://example.com/x — explains Y; bears on sub-question 2"),
)
assertEquals(setOf("example.com"), hosts)
assertTrue("example.com" in hosts)
}
}
@@ -0,0 +1,482 @@
package com.correx.apps.server.routes
import com.correx.apps.server.ServerModule
import com.correx.apps.server.configureServer
import com.correx.apps.server.registry.ProviderRegistry
import com.correx.apps.server.registry.WorkflowRegistry
import com.correx.apps.server.registry.WorkflowSummary
import com.correx.apps.server.undo.SessionUndoService
import com.correx.core.artifactstore.ArtifactStore
import com.correx.core.context.builder.DefaultContextPackBuilder
import com.correx.core.context.compression.DefaultContextCompressor
import com.correx.core.events.EventDispatcher
import com.correx.core.events.stores.EventStore
import com.correx.core.events.types.ArtifactId
import com.correx.core.inference.InferenceProjector
import com.correx.core.inference.InferenceProvider
import com.correx.core.inference.InferenceRepository
import com.correx.core.inference.ProviderHealth
import com.correx.core.kernel.orchestration.DefaultOrchestrationReducer
import com.correx.core.kernel.orchestration.DefaultSessionOrchestrator
import com.correx.core.kernel.orchestration.OrchestrationConfig
import com.correx.core.kernel.orchestration.OrchestrationProjector
import com.correx.core.kernel.orchestration.OrchestrationRepository
import com.correx.core.kernel.orchestration.OrchestratorEngines
import com.correx.core.kernel.orchestration.OrchestratorRepositories
import com.correx.core.kernel.retry.DefaultRetryCoordinator
import com.correx.core.events.types.ProviderId
import com.correx.core.risk.DefaultRiskAssessor
import com.correx.core.router.model.RouterConfig
import com.correx.core.sessions.DefaultSessionReducer
import com.correx.core.sessions.DefaultSessionRepository
import com.correx.core.sessions.SessionProjector
import com.correx.core.sessions.projections.replay.DefaultEventReplayer
import com.correx.core.transitions.graph.WorkflowGraph
import com.correx.core.transitions.resolution.DefaultTransitionResolver
import com.correx.core.validation.pipeline.ValidationPipeline
import com.correx.infrastructure.InfrastructureModule
import com.correx.infrastructure.inference.commons.UnavailableProbe
import com.correx.infrastructure.persistence.InMemoryEventStore
import com.correx.infrastructure.tools.FileEditConfig
import com.correx.infrastructure.tools.FileReadConfig
import com.correx.infrastructure.tools.FileWriteConfig
import com.correx.infrastructure.tools.ShellConfig
import com.correx.infrastructure.tools.ToolConfig
import io.ktor.client.HttpClient
import io.ktor.client.request.delete
import io.ktor.client.request.get
import io.ktor.client.request.patch
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.client.statement.HttpResponse
import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType
import io.ktor.http.HttpStatusCode
import io.ktor.http.contentType
import io.ktor.server.testing.testApplication
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonPrimitive
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
class TaskRoutesTest {
private val testJson = Json { ignoreUnknownKeys = true }
private suspend fun HttpResponse.json(): JsonObject =
testJson.parseToJsonElement(bodyAsText()) as JsonObject
private suspend fun HttpClient.createTask(project: String = "demo"): JsonObject =
post("/tasks") {
contentType(ContentType.Application.Json)
setBody("""{"project":"$project","title":"JWT refresh","goal":"users stay authed"}""")
}.json()
@Test
fun `POST creates a task and GET lists and fetches it`(@TempDir tempDir: Path) {
testApplication {
application { configureServer(buildModule(tempDir)) }
val client = createClient {}
val created = client.post("/tasks") {
contentType(ContentType.Application.Json)
setBody(
"""{"project":"demo","title":"JWT refresh","goal":"stay authed",
"acceptanceCriteria":["rotates"],"affectedPaths":["auth/**"]}""",
)
}
assertEquals(HttpStatusCode.Created, created.status)
val body = created.json()
assertEquals("demo-1", body["id"]!!.jsonPrimitive.content)
assertEquals("TODO", body["status"]!!.jsonPrimitive.content)
assertEquals("rotates", body["acceptanceCriteria"]!!.jsonArray.single().jsonPrimitive.content)
val list = client.get("/tasks?project=demo")
assertEquals(HttpStatusCode.OK, list.status)
val rows = testJson.parseToJsonElement(list.bodyAsText()) as JsonArray
assertEquals(1, rows.size)
assertEquals("demo-1", (rows[0] as JsonObject)["id"]!!.jsonPrimitive.content)
// No project → cross-project board lists it too.
val all = client.get("/tasks")
assertEquals(HttpStatusCode.OK, all.status)
val allRows = testJson.parseToJsonElement(all.bodyAsText()) as JsonArray
assertEquals(1, allRows.size)
assertEquals("demo-1", (allRows[0] as JsonObject)["id"]!!.jsonPrimitive.content)
val fetched = client.get("/tasks/demo-1")
assertEquals(HttpStatusCode.OK, fetched.status)
assertEquals("JWT refresh", fetched.json()["title"]!!.jsonPrimitive.content)
}
}
@Test
fun `GET tasks reports dependency-graph ready and blockedBy`(@TempDir tempDir: Path) {
testApplication {
application { configureServer(buildModule(tempDir)) }
val client = createClient {}
client.createTask() // demo-1 (TODO, no deps)
client.post("/tasks") {
contentType(ContentType.Application.Json)
setBody("""{"project":"demo","title":"Rotate keys","goal":"rotate signing keys"}""")
} // demo-2
// demo-2 DEPENDS_ON demo-1 (TASK kind inferred from the id).
client.post("/tasks/demo-2/links") {
contentType(ContentType.Application.Json)
setBody("""{"targetId":"demo-1","type":"DEPENDS_ON"}""")
}
val rows = testJson.parseToJsonElement(client.get("/tasks?project=demo").bodyAsText()) as JsonArray
val byId = rows.associate { (it as JsonObject)["id"]!!.jsonPrimitive.content to it }
val one = byId.getValue("demo-1") as JsonObject
val two = byId.getValue("demo-2") as JsonObject
// default-valued fields are omitted from JSON (encodeDefaults=false), which the TUI reads
// back as zero values — so tolerate absence here too.
fun readyOf(o: JsonObject) = o["ready"]?.jsonPrimitive?.content == "true"
fun blockedByOf(o: JsonObject): List<String> =
(o["blockedBy"] as? JsonArray)?.map { it.jsonPrimitive.content } ?: emptyList()
// demo-1: TODO with nothing in its way → ready, no blockers.
assertTrue(readyOf(one))
assertTrue(blockedByOf(one).isEmpty())
// demo-2: waits on the unfinished demo-1 → not ready, blockedBy = [demo-1].
assertTrue(!readyOf(two))
assertEquals(listOf("demo-1"), blockedByOf(two))
}
}
@Test
fun `lifecycle transitions move a task to DONE`(@TempDir tempDir: Path) {
testApplication {
application { configureServer(buildModule(tempDir)) }
val client = createClient {}
client.createTask()
val claimed = client.post("/tasks/demo-1/claim") {
contentType(ContentType.Application.Json)
setBody("""{"claimant":"claude-opus"}""")
}
assertEquals("IN_PROGRESS", claimed.json()["status"]!!.jsonPrimitive.content)
assertEquals("claude-opus", claimed.json()["claimant"]!!.jsonPrimitive.content)
val reviewed = client.post("/tasks/demo-1/submit-for-review").json()
assertEquals("IN_REVIEW", reviewed["status"]!!.jsonPrimitive.content)
val completed = client.post("/tasks/demo-1/complete").json()
assertEquals("DONE", completed["status"]!!.jsonPrimitive.content)
}
}
@Test
fun `PATCH edits fields and POST links and notes mutate the graph`(@TempDir tempDir: Path) {
testApplication {
application { configureServer(buildModule(tempDir)) }
val client = createClient {}
client.createTask()
val patched = client.patch("/tasks/demo-1") {
contentType(ContentType.Application.Json)
setBody("""{"title":"JWT refresh v2"}""")
}
assertEquals("JWT refresh v2", patched.json()["title"]!!.jsonPrimitive.content)
// No explicit kind: an ADR id infers to DOC.
val linked = client.post("/tasks/demo-1/links") {
contentType(ContentType.Application.Json)
setBody("""{"targetId":"adr-7","type":"IMPLEMENTS"}""")
}
val link = linked.json()["links"]!!.jsonArray.single() as JsonObject
assertEquals("adr-7", link["targetId"]!!.jsonPrimitive.content)
assertEquals("IMPLEMENTS", link["type"]!!.jsonPrimitive.content)
assertEquals("DOC", link["targetKind"]!!.jsonPrimitive.content)
val noted = client.post("/tasks/demo-1/notes") {
contentType(ContentType.Application.Json)
setBody("""{"body":"kickoff"}""")
}
val note = noted.json()["notes"]!!.jsonArray.single() as JsonObject
assertEquals("kickoff", note["body"]!!.jsonPrimitive.content)
assertEquals("HUMAN", note["author"]!!.jsonPrimitive.content)
// Unlink via query params clears the edge.
val unlinked = client.delete("/tasks/demo-1/links?targetId=adr-7&type=IMPLEMENTS")
assertTrue(unlinked.json()["links"]!!.jsonArray.isEmpty())
}
}
@Test
fun `DELETE soft-deletes and the task disappears from reads`(@TempDir tempDir: Path) {
testApplication {
application { configureServer(buildModule(tempDir)) }
val client = createClient {}
client.createTask()
assertEquals(HttpStatusCode.NoContent, client.delete("/tasks/demo-1").status)
assertEquals(HttpStatusCode.NotFound, client.get("/tasks/demo-1").status)
assertEquals(HttpStatusCode.NotFound, client.delete("/tasks/demo-1").status)
}
}
@Test
fun `GET tasks with q returns only ranked search hits`(@TempDir tempDir: Path) {
testApplication {
application { configureServer(buildModule(tempDir)) }
val client = createClient {}
client.createTask() // demo-1: "JWT refresh" / "users stay authed"
client.post("/tasks") {
contentType(ContentType.Application.Json)
setBody("""{"project":"demo","title":"Rotate keys","goal":"rotate signing keys"}""")
}
val res = client.get("/tasks?q=jwt")
assertEquals(HttpStatusCode.OK, res.status)
val rows = testJson.parseToJsonElement(res.bodyAsText()) as JsonArray
assertEquals(1, rows.size)
assertEquals("demo-1", (rows[0] as JsonObject)["id"]!!.jsonPrimitive.content)
}
}
@Test
fun `POST sync-git advances tasks from commit messages`(@TempDir tempDir: Path) {
testApplication {
application { configureServer(buildModule(tempDir)) }
val client = createClient {}
client.createTask() // demo-1, TODO
val res = client.post("/tasks/sync-git") {
contentType(ContentType.Application.Json)
setBody("""{"commits":[{"sha":"abc1234567","message":"fixes demo-1"}]}""")
}
assertEquals(HttpStatusCode.OK, res.status)
val change = (res.json()["changes"] as JsonArray).single() as JsonObject
assertEquals("demo-1", change["key"]!!.jsonPrimitive.content)
assertEquals("TODO", change["from"]!!.jsonPrimitive.content)
assertEquals("DONE", change["to"]!!.jsonPrimitive.content)
assertEquals("DONE", client.get("/tasks/demo-1").json()["status"]!!.jsonPrimitive.content)
}
}
@Test
fun `GET tasks export renders the board as markdown`(@TempDir tempDir: Path) {
testApplication {
application { configureServer(buildModule(tempDir)) }
val client = createClient {}
client.createTask() // demo-1
val res = client.get("/tasks/export")
assertEquals(HttpStatusCode.OK, res.status)
val md = res.bodyAsText()
assertTrue(md.startsWith("# Tasks"))
assertTrue(md.contains("**demo-1**"))
}
}
@Test
fun `context bundle is served for a known task`(@TempDir tempDir: Path) {
testApplication {
application { configureServer(buildModule(tempDir)) }
val client = createClient {}
client.createTask()
val bundle = client.get("/tasks/demo-1/context")
assertEquals(HttpStatusCode.OK, bundle.status)
assertEquals("demo-1", bundle.json()["id"]!!.jsonPrimitive.content)
assertEquals(HttpStatusCode.NotFound, client.get("/tasks/demo-999/context").status)
}
}
@Test
fun `ready returns unblocked TODO tasks and blockers explains a wait`(@TempDir tempDir: Path) {
testApplication {
application { configureServer(buildModule(tempDir)) }
val client = createClient {}
client.createTask() // demo-1, TODO
client.post("/tasks") {
contentType(ContentType.Application.Json)
setBody("""{"project":"demo","title":"second","goal":"g"}""")
}
// demo-2 depends on demo-1 (id infers to a TASK target).
client.post("/tasks/demo-2/links") {
contentType(ContentType.Application.Json)
setBody("""{"targetId":"demo-1","type":"DEPENDS_ON"}""")
}
val ready = client.get("/tasks?ready=true")
assertEquals(HttpStatusCode.OK, ready.status)
val rows = testJson.parseToJsonElement(ready.bodyAsText()) as JsonArray
assertEquals(1, rows.size)
assertEquals("demo-1", (rows[0] as JsonObject)["id"]!!.jsonPrimitive.content)
val blockers = client.get("/tasks/demo-2/blockers")
assertEquals(HttpStatusCode.OK, blockers.status)
val blk = testJson.parseToJsonElement(blockers.bodyAsText()) as JsonArray
assertEquals("demo-1", (blk.single() as JsonObject)["id"]!!.jsonPrimitive.content)
}
}
@Test
fun `GET history renders the lifecycle timeline`(@TempDir tempDir: Path) {
testApplication {
application { configureServer(buildModule(tempDir)) }
val client = createClient {}
client.createTask() // demo-1
client.post("/tasks/demo-1/claim") {
contentType(ContentType.Application.Json)
setBody("""{"claimant":"claude-opus"}""")
}
val res = client.get("/tasks/demo-1/history")
assertEquals(HttpStatusCode.OK, res.status)
val timeline = res.bodyAsText()
assertTrue(timeline.contains("created demo-1"))
assertTrue(timeline.contains("claimed by claude-opus"))
// An id that never existed has no history → 404.
assertEquals(HttpStatusCode.NotFound, client.get("/tasks/demo-999/history").status)
}
}
@Test
fun `POST rejects a duplicate title with 409 and force overrides`(@TempDir tempDir: Path) {
testApplication {
application { configureServer(buildModule(tempDir)) }
val client = createClient {}
client.createTask() // demo-1 "JWT refresh"
val dup = client.post("/tasks") {
contentType(ContentType.Application.Json)
setBody("""{"project":"demo","title":"jwt refresh","goal":"g"}""")
}
assertEquals(HttpStatusCode.Conflict, dup.status)
val rows = testJson.parseToJsonElement(dup.bodyAsText()) as JsonArray
assertEquals("demo-1", (rows.single() as JsonObject)["id"]!!.jsonPrimitive.content)
val forced = client.post("/tasks") {
contentType(ContentType.Application.Json)
setBody("""{"project":"demo","title":"jwt refresh","goal":"g","force":true}""")
}
assertEquals(HttpStatusCode.Created, forced.status)
}
}
@Test
fun `bad requests are rejected`(@TempDir tempDir: Path) {
testApplication {
application { configureServer(buildModule(tempDir)) }
val client = createClient {}
client.createTask()
assertEquals(HttpStatusCode.BadRequest, client.get("/tasks?status=NONSENSE").status)
assertEquals(HttpStatusCode.NotFound, client.get("/tasks/nope-1").status)
val badLink = client.post("/tasks/demo-1/links") {
contentType(ContentType.Application.Json)
setBody("""{"targetId":"x","type":"NONSENSE"}""")
}
assertEquals(HttpStatusCode.BadRequest, badLink.status)
}
}
// --- harness ----------------------------------------------------------------------------
private fun buildModule(tempDir: Path): ServerModule {
val eventStore: EventStore = InMemoryEventStore()
val artifactStore = object : ArtifactStore {
override suspend fun put(bytes: ByteArray): ArtifactId = ArtifactId("noop")
override suspend fun get(id: ArtifactId): ByteArray? = null
override suspend fun flushBefore(commit: suspend () -> Unit) = commit()
}
val provider = InfrastructureModule.createLlamaCppProvider(
modelId = "test-model",
modelPath = "/dev/null",
baseUrl = "http://127.0.0.1:1",
)
val providerRegistry = InfrastructureModule.createProviderRegistry(listOf(provider))
val inferenceRouter = com.correx.core.inference.DefaultInferenceRouter(
providerRegistry,
com.correx.infrastructure.inference.FirstAvailableRoutingStrategy(),
)
val toolConfig = ToolConfig(
shell = ShellConfig(enabled = false, allowedExecutables = emptySet(), workingDir = tempDir),
fileRead = FileReadConfig(enabled = false, allowedPaths = setOf(tempDir)),
fileWrite = FileWriteConfig(enabled = false, allowedPaths = setOf(tempDir), workingDir = tempDir),
fileEdit = FileEditConfig(enabled = false, allowedPaths = setOf(tempDir), workingDir = tempDir),
)
val toolRegistry = InfrastructureModule.createToolRegistry(toolConfig)
val eventDispatcher = EventDispatcher(eventStore)
val toolExecutor = InfrastructureModule.createToolExecutor(
registry = toolRegistry,
eventDispatcher = eventDispatcher,
workDir = tempDir,
artifactStore = null,
)
val engines = OrchestratorEngines(
transitionResolver = DefaultTransitionResolver { condition, ctx -> condition.evaluate(ctx) },
contextPackBuilder = DefaultContextPackBuilder(DefaultContextCompressor()),
inferenceRouter = inferenceRouter,
validationPipeline = ValidationPipeline(validators = emptyList()),
approvalEngine = com.correx.core.approvals.domain.DefaultApprovalEngine(),
riskAssessor = DefaultRiskAssessor(),
toolRegistry = toolRegistry,
toolExecutor = toolExecutor,
)
val repositories = OrchestratorRepositories(
eventStore = eventStore,
inferenceRepository = InferenceRepository(DefaultEventReplayer(eventStore, InferenceProjector())),
orchestrationRepository = OrchestrationRepository(
DefaultEventReplayer(eventStore, OrchestrationProjector(DefaultOrchestrationReducer())),
),
sessionRepository = DefaultSessionRepository(
DefaultEventReplayer(eventStore, SessionProjector(DefaultSessionReducer())),
),
artifactRepository = InfrastructureModule.createArtifactRepository(eventStore),
approvalRepository = InfrastructureModule.createApprovalRepository(eventStore),
)
val orchestrator = DefaultSessionOrchestrator(
repositories = repositories,
engines = engines,
retryCoordinator = DefaultRetryCoordinator(eventStore),
artifactStore = artifactStore,
tokenizer = provider.tokenizer,
decisionJournalRepository = InfrastructureModule.createDecisionJournalRepository(eventStore),
)
val routerFacade = InfrastructureModule.createRouterFacade(
eventStore = eventStore,
inferenceRouter = inferenceRouter,
config = RouterConfig(),
tokenizer = provider.tokenizer,
)
val noopProviderRegistry = object : ProviderRegistry {
override fun listAll() = emptyList<InferenceProvider>()
override suspend fun healthCheckAll() = emptyMap<ProviderId, ProviderHealth>()
}
val noopWorkflowRegistry = object : WorkflowRegistry {
override fun listAll(): List<WorkflowSummary> = emptyList()
override fun find(workflowId: String): WorkflowGraph? = null
}
val sessionUndoService = SessionUndoService(
eventStore = eventStore,
artifactStore = artifactStore,
bootRoots = setOf(tempDir),
)
return ServerModule(
orchestrator = orchestrator,
eventStore = eventStore,
artifactStore = artifactStore,
sessionRepository = repositories.sessionRepository,
workflowRegistry = noopWorkflowRegistry,
providerRegistry = noopProviderRegistry,
defaultOrchestrationConfig = OrchestrationConfig(sandboxRoot = tempDir),
routerFacade = routerFacade,
orchestrationRepository = repositories.orchestrationRepository,
approvalRepository = repositories.approvalRepository,
toolRegistry = toolRegistry,
sessionUndoService = sessionUndoService,
resourceProbe = UnavailableProbe,
)
}
}
@@ -0,0 +1,72 @@
package com.correx.apps.server.tasks
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
import kotlin.io.path.createDirectories
import kotlin.io.path.writeText
class FileTaskDocumentResolverTest {
@TempDir
lateinit var repo: Path
private fun writeAdr() {
val dir = repo.resolve("docs/decisions").also { it.createDirectories() }
dir.resolve("adr-0007-redis.md").writeText(
"""
---
name: "ADR 7 Redis Sessions"
description: "use redis for session storage"
---
# ADR 7: use redis for session storage
**status:** accepted
## decision
Redis backs session storage.
""".trimIndent(),
)
}
@Test
fun `resolves an ADR id padding-agnostically`() = runBlocking {
writeAdr()
val resolver = FileTaskDocumentResolver(repo)
val byShort = resolver.resolve("adr-7")
val byPadded = resolver.resolve("ADR-0007")
assertEquals("ADR 7 Redis Sessions", byShort?.title)
assertEquals("use redis for session storage", byShort?.excerpt)
assertEquals("docs/decisions/adr-0007-redis.md", byShort?.path)
assertEquals(byShort, byPadded)
}
@Test
fun `unknown ADR resolves to null`() = runBlocking {
writeAdr()
assertNull(FileTaskDocumentResolver(repo).resolve("adr-999"))
}
@Test
fun `resolves an explicit markdown path and falls back to heading and first paragraph`() = runBlocking {
repo.resolve("docs/notes").createDirectories()
repo.resolve("docs/notes/plan.md").writeText("# Plan\n\nThe plan body.\n")
val doc = FileTaskDocumentResolver(repo).resolve("docs/notes/plan.md")
assertEquals("Plan", doc?.title)
assertEquals("The plan body.", doc?.excerpt)
assertEquals("docs/notes/plan.md", doc?.path)
}
@Test
fun `non-document target resolves to null`() = runBlocking {
assertNull(FileTaskDocumentResolver(repo).resolve("ext-123"))
}
}
@@ -0,0 +1,30 @@
package com.correx.apps.server.tasks
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
class GitCommandCommitReaderTest {
private val us = ""
private val rs = ""
@Test
fun `parseGitLog splits records and sha from message`() {
val raw = "abc123${us}fix auth-1: rotate$rs\ndef456${us}wip on auth-2$rs\n"
val commits = parseGitLog(raw)
assertEquals(2, commits.size)
assertEquals("abc123", commits[0].sha)
assertEquals("fix auth-1: rotate", commits[0].message)
assertEquals("def456", commits[1].sha)
assertEquals("wip on auth-2", commits[1].message)
}
@Test
fun `parseGitLog tolerates blank output and malformed records`() {
assertTrue(parseGitLog("").isEmpty())
assertTrue(parseGitLog("no-separator-here$rs").isEmpty())
}
}
@@ -0,0 +1,83 @@
package com.correx.apps.server.tasks
import com.correx.core.artifactstore.ArtifactStore
import com.correx.core.events.events.ArtifactContentStoredEvent
import com.correx.core.events.events.ArtifactCreatedEvent
import com.correx.core.events.events.EventMetadata
import com.correx.core.events.events.InitialIntentEvent
import com.correx.core.events.events.NewEvent
import com.correx.core.events.events.WorkflowCompletedEvent
import com.correx.core.events.events.WorkflowStartedEvent
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.infrastructure.persistence.InMemoryEventStore
import kotlinx.coroutines.runBlocking
import kotlinx.datetime.Clock
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test
import java.util.UUID
class TaskLinkResolverTest {
private val store: EventStore = InMemoryEventStore()
private suspend fun append(sessionId: SessionId, payload: com.correx.core.events.events.EventPayload) {
store.append(
NewEvent(
metadata = EventMetadata(
eventId = EventId(UUID.randomUUID().toString()),
sessionId = sessionId,
timestamp = Clock.System.now(),
schemaVersion = 1,
causationId = null,
correlationId = null,
),
payload = payload,
),
)
}
@Test
fun `session resolver projects status and intent and is null for an unknown id`() = runBlocking {
val sessionId = SessionId("sess-9")
append(sessionId, InitialIntentEvent(sessionId, "build auth"))
append(sessionId, WorkflowStartedEvent(sessionId, "role_pipeline", StageId("architect")))
append(sessionId, WorkflowCompletedEvent(sessionId, StageId("review"), totalStages = 3))
val resolver = SessionSummaryTaskSessionResolver(store)
val resolved = resolver.resolve("sess-9")!!
assertEquals("COMPLETED", resolved.status)
assertEquals("build auth", resolved.intent)
assertEquals("role_pipeline", resolved.workflowId)
assertNull(resolver.resolve("sess-unknown"))
}
@Test
fun `artifact resolver reads stage, session and content excerpt and is null for an unknown id`() = runBlocking {
val sessionId = SessionId("sess-9")
val artifactId = ArtifactId("art-1")
val contentHash = ArtifactId("hash-1")
val artifactStore = FakeArtifactStore(mapOf(contentHash to "approach: use redis".toByteArray()))
append(sessionId, ArtifactContentStoredEvent(artifactId, contentHash, sessionId, StageId("architect")))
append(sessionId, ArtifactCreatedEvent(artifactId, sessionId, StageId("architect"), schemaVersion = 1))
val resolver = EventStoreTaskArtifactResolver(store, artifactStore)
val resolved = resolver.resolve("art-1")!!
assertEquals("architect", resolved.stage)
assertEquals("sess-9", resolved.sessionId)
assertEquals("approach: use redis", resolved.excerpt)
assertNull(resolver.resolve("art-unknown"))
}
private class FakeArtifactStore(private val blobs: Map<ArtifactId, ByteArray>) : ArtifactStore {
override suspend fun put(bytes: ByteArray): ArtifactId = error("unused")
override suspend fun get(id: ArtifactId): ByteArray? = blobs[id]
override suspend fun flushBefore(commit: suspend () -> Unit) = commit()
}
}
@@ -0,0 +1,55 @@
package com.correx.apps.server.workspace
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Files
import java.nio.file.Path
import kotlin.io.path.createDirectories
import kotlin.io.path.writeText
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class WorkspaceFilesTest {
@Test
fun `lists all file types by relative path, sorted`(@TempDir root: Path) {
root.resolve("src").createDirectories()
root.resolve("src/Main.kt").writeText("fun main() {}")
root.resolve("config.toml").writeText("a = 1") // not a source ext — must still appear
root.resolve("README.md").writeText("# hi")
val paths = WorkspaceFiles.list(root)
assertEquals(listOf("README.md", "config.toml", "src/Main.kt"), paths)
}
@Test
fun `skips ignored directories`(@TempDir root: Path) {
root.resolve(".git").createDirectories()
root.resolve(".git/HEAD").writeText("ref")
root.resolve("node_modules/pkg").createDirectories()
root.resolve("node_modules/pkg/index.js").writeText("x")
root.resolve("keep.txt").writeText("y")
val paths = WorkspaceFiles.list(root)
assertEquals(listOf("keep.txt"), paths)
assertFalse(paths.any { it.startsWith(".git") || it.startsWith("node_modules") })
}
@Test
fun `caps the result count`(@TempDir root: Path) {
repeat(10) { root.resolve("f$it.txt").writeText("x") }
val paths = WorkspaceFiles.list(root, limit = 3)
assertEquals(3, paths.size)
assertTrue(paths.all { it.endsWith(".txt") })
}
@Test
fun `missing root yields empty`(@TempDir root: Path) {
val missing = root.resolve("nope")
assertTrue(Files.notExists(missing))
assertEquals(emptyList(), WorkspaceFiles.list(missing))
}
}
+1
View File
@@ -1 +1,2 @@
/tui-go
/preview
+3 -4
View File
@@ -7,14 +7,13 @@ import (
"os"
"strconv"
"github.com/charmbracelet/lipgloss"
"github.com/correx/tui-go/internal/app"
"github.com/muesli/termenv"
)
func main() {
lipgloss.SetColorProfile(termenv.TrueColor)
// lipgloss v2 renders full truecolor ANSI by default (color downsampling now
// lives in the terminal writer, not a global profile), which is exactly what a
// static screenshot frame wants — so no color-profile setup is needed.
kind := "idle"
if len(os.Args) > 1 {
kind = os.Args[1]
+30 -17
View File
@@ -1,32 +1,45 @@
module github.com/correx/tui-go
go 1.26
go 1.25.0
require (
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
github.com/charmbracelet/x/ansi v0.11.6
charm.land/bubbletea/v2 v2.0.7
charm.land/lipgloss/v2 v2.0.4
github.com/aymanbagabas/go-osc52/v2 v2.0.1
github.com/charmbracelet/glamour v1.0.0
github.com/charmbracelet/x/ansi v0.11.7
github.com/gorilla/websocket v1.5.3
github.com/muesli/termenv v0.16.0
)
require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.4.1 // indirect
github.com/alecthomas/chroma/v2 v2.20.0 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/charmbracelet/colorprofile v0.4.3 // indirect
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260525132238-948f4557a654 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.9.0 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/charmbracelet/x/termios v0.1.1 // indirect
github.com/charmbracelet/x/windows v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/mattn/go-runewidth v0.0.23 // indirect
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/reflow v0.3.0 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.3.8 // indirect
github.com/yuin/goldmark v1.7.13 // indirect
github.com/yuin/goldmark-emoji v1.0.6 // indirect
golang.org/x/net v0.38.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/term v0.36.0 // indirect
golang.org/x/text v0.30.0 // indirect
)
+67 -29
View File
@@ -1,50 +1,88 @@
charm.land/bubbletea/v2 v2.0.7 h1:7qw2tTAVar7m7klOPBYfTB0mniv/RuexsYwMRNxSeL0=
charm.land/bubbletea/v2 v2.0.7/go.mod h1:DGW2q8gvzHnOpMpZTORs0aySVHCox5C+2Svk0fci1qs=
charm.land/lipgloss/v2 v2.0.4 h1:lcPeVtcp23SNra7lHy8iYE4UC2aIipVQ47sbGyyxR5Q=
charm.land/lipgloss/v2 v2.0.4/go.mod h1:0653x8epbZSzdDfO/XPS1a/uYPOBeSsCssOpJOqDzik=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw=
github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA=
github.com/alecthomas/repr v0.5.1 h1:E3G4t2QbHTSNpPKBgMTln5KLkZHLOcU7r37J4pXBuIg=
github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
github.com/charmbracelet/glamour v1.0.0 h1:AWMLOVFHTsysl4WV8T8QgkQ0s/ZNZo7CiE4WKhk8l08=
github.com/charmbracelet/glamour v1.0.0/go.mod h1:DSdohgOBkMr2ZQNhw4LZxSGpx3SvpeujNoXrQyH2hxo=
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
github.com/charmbracelet/ultraviolet v0.0.0-20260525132238-948f4557a654 h1:FpSYhY28ucg9ZRr+2wj67FAQ0Ey5yiK0072PmRDJNek=
github.com/charmbracelet/ultraviolet v0.0.0-20260525132238-948f4557a654/go.mod h1:hFpumms29Smx3LStRfku8vcCTBe1Kq8aCXtHUJa3mjY=
github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI=
github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ=
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI=
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA=
github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
@@ -4,7 +4,7 @@ import (
"strings"
"testing"
"github.com/charmbracelet/lipgloss"
"charm.land/lipgloss/v2"
)
// inSessionModel builds an in-session model with deliberately long fields (model name,
@@ -40,7 +40,7 @@ func assertNoOverflow(t *testing.T, out string, w int) {
func TestViewNeverOverflowsWidth(t *testing.T) {
for _, w := range []int{40, 50, 60, 80, 95, 96, 110, 160} {
m := inSessionModel(w, 30)
assertNoOverflow(t, m.View(), w)
assertNoOverflow(t, m.render(), w)
}
}
@@ -51,7 +51,7 @@ func TestStatsOverlayNeverOverflowsWidth(t *testing.T) {
m.overlay = OverlayStats
m.statsFor = m.selectedID
m.stats = sampleStats()
assertNoOverflow(t, m.View(), w)
assertNoOverflow(t, m.render(), w)
}
}
+256
View File
@@ -0,0 +1,256 @@
package app
import (
"encoding/json"
"strings"
"testing"
"github.com/correx/tui-go/internal/protocol"
"github.com/correx/tui-go/internal/ws"
)
// approval_test.go covers the approval-ergonomics gaps (BACKLOG §E §3): single-key
// approve/reject/steer for low tiers, an arm→confirm gate for T3+, and a navigable
// pending-approval queue (never modal-stacked). Deterministic: an unconnected ws
// client buffers Sent frames, Drain reads them back.
// approvalModel builds an entered session and queues each given approval through the
// production applyServer path, so the queue/Pending state matches real event flow.
func approvalModel(approvals ...protocol.ServerMessage) (Model, *ws.Client) {
client := ws.New("", 0) // unconnected; Send buffers, Drain reads it back
m := NewModel(client)
m.width, m.height = 120, 40
m.theme = NewTheme(SoftBlue)
m.selectedID = "s1"
m.sessionEntered = true
m.sessions = []Session{{ID: "s1", Status: "ACTIVE", Name: "healthcheck", LastEventAt: fixedNow}}
for _, a := range approvals {
m.applyServer(a)
}
return m, client
}
// apprReq builds an approval.required frame for the given request id and tier.
func apprReq(reqID, tier string) protocol.ServerMessage {
return msg(protocol.TypeApprovalRequired, withTool("file_write"), func(m *protocol.ServerMessage) {
m.RequestID, m.Tier = reqID, tier
m.RiskSummary = &protocol.RiskSummaryDto{Level: "LOW"}
})
}
// applyKey drives a key through the production handleKey entry and returns the model.
func applyKey(m Model, k keyMsg) Model {
updated, _ := m.handleKey(k)
return updated.(Model)
}
func runeKey(r rune) keyMsg { return keyMsg{Type: keyRunes, Runes: []rune{r}} }
// decodeApproval pulls the single ApprovalResponse frame off the client and returns
// its request id + decision. Fails if there isn't exactly one decision frame.
func decodeApproval(t *testing.T, client *ws.Client) (requestID, decision string) {
t.Helper()
var resp map[string]any
n := 0
for _, f := range client.Drain() {
var raw map[string]any
if err := json.Unmarshal(f, &raw); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if raw["type"] == "com.correx.apps.server.protocol.ClientMessage.ApprovalResponse" {
resp = raw
n++
}
}
if n != 1 {
t.Fatalf("expected exactly one ApprovalResponse frame, got %d", n)
}
rid, _ := resp["requestId"].(string)
dec, _ := resp["decision"].(string)
return rid, dec
}
// noApprovalSent asserts the client buffered no ApprovalResponse frame.
func noApprovalSent(t *testing.T, client *ws.Client) {
t.Helper()
for _, f := range client.Drain() {
var raw map[string]any
_ = json.Unmarshal(f, &raw)
if raw["type"] == "com.correx.apps.server.protocol.ClientMessage.ApprovalResponse" {
t.Fatalf("did not expect an ApprovalResponse frame yet")
}
}
}
func TestApprovalLowTierApprovesImmediately(t *testing.T) {
m, client := approvalModel(apprReq("req-1", "T1"))
if m.displayState() != StateApproval {
t.Fatalf("want StateApproval, got %v", m.displayState())
}
m = applyKey(m, runeKey('y'))
rid, dec := decodeApproval(t, client)
if rid != "req-1" || dec != "APPROVE" {
t.Fatalf("want approve req-1, got %s/%s", rid, dec)
}
if s := m.session("s1"); s == nil || s.Pending != nil {
t.Fatalf("pending gate should be cleared after a low-tier approve")
}
if m.approvalArmed {
t.Fatalf("low-tier approve must not leave an armed confirm")
}
}
func TestApprovalHighTierRequiresSecondConfirm(t *testing.T) {
m, client := approvalModel(apprReq("req-1", "T3"))
// First `y` arms; nothing is sent yet.
m = applyKey(m, runeKey('y'))
if !m.approvalArmed {
t.Fatalf("T3 approve should arm a confirm, not send")
}
noApprovalSent(t, client)
if s := m.session("s1"); s == nil || s.Pending == nil {
t.Fatalf("gate must remain pending while armed")
}
// Second `y` confirms and sends.
m = applyKey(m, runeKey('y'))
rid, dec := decodeApproval(t, client)
if rid != "req-1" || dec != "APPROVE" {
t.Fatalf("want approve req-1 after confirm, got %s/%s", rid, dec)
}
if m.approvalArmed {
t.Fatalf("arm should clear after the confirming keypress")
}
}
func TestApprovalHighTierArmCancelledByOtherKey(t *testing.T) {
m, client := approvalModel(apprReq("req-1", "T3"))
m = applyKey(m, runeKey('y')) // arm
if !m.approvalArmed {
t.Fatalf("expected armed after first y")
}
// A non-confirm key (here `n` reject) cancels the arm. The arm itself must be
// gone, and no spurious APPROVE may slip through.
m = applyKey(m, runeKey('n'))
if m.approvalArmed {
t.Fatalf("arm should be cancelled by a non-confirm key")
}
rid, dec := decodeApproval(t, client)
if dec != "REJECT" || rid != "req-1" {
t.Fatalf("non-confirm key should route to its own action (reject), got %s/%s", rid, dec)
}
}
func TestApprovalHighTierArmCancelledByNavKey(t *testing.T) {
// Two T3 gates: arming then pressing ↓ must disarm and only move the cursor —
// no decision may be emitted.
m, client := approvalModel(apprReq("req-1", "T3"), apprReq("req-2", "T3"))
m = applyKey(m, runeKey('y')) // arm req-1
m = applyKey(m, keyMsg{Type: keyDown})
if m.approvalArmed {
t.Fatalf("nav key should disarm")
}
noApprovalSent(t, client)
if s := m.session("s1"); s == nil || s.PendingIdx != 1 {
t.Fatalf("expected cursor advanced to index 1, got %d", s.PendingIdx)
}
}
func TestApprovalQueueNavigatesAndCounts(t *testing.T) {
m, _ := approvalModel(apprReq("req-1", "T1"), apprReq("req-2", "T1"), apprReq("req-3", "T1"))
s := m.session("s1")
if s == nil || len(s.PendingQueue) != 3 {
t.Fatalf("expected 3 queued approvals, got %d", len(s.PendingQueue))
}
// The band shows a "1/3" count and the current request, never stacked modals.
out := stripANSI(m.renderApprovalBand(m.approvalBandHeight()))
if !strings.Contains(out, "1/3") {
t.Fatalf("band should show queue count 1/3:\n%s", out)
}
// ↓ advances the selection; Pending follows the cursor.
m = applyKey(m, keyMsg{Type: keyDown})
s = m.session("s1")
if s.PendingIdx != 1 || s.Pending.RequestID != "req-2" {
t.Fatalf("↓ should select req-2, got idx=%d id=%s", s.PendingIdx, s.Pending.RequestID)
}
out = stripANSI(m.renderApprovalBand(m.approvalBandHeight()))
if !strings.Contains(out, "2/3") {
t.Fatalf("band should show 2/3 after ↓:\n%s", out)
}
// ↑ wraps back.
m = applyKey(m, keyMsg{Type: keyUp})
if m.session("s1").PendingIdx != 0 {
t.Fatalf("↑ should return to index 0")
}
}
func TestApprovalResolveFrontAdvancesQueue(t *testing.T) {
m, client := approvalModel(apprReq("req-1", "T1"), apprReq("req-2", "T1"))
// Approve the front gate (low tier → immediate).
m = applyKey(m, runeKey('y'))
if rid, dec := decodeApproval(t, client); rid != "req-1" || dec != "APPROVE" {
t.Fatalf("want approve req-1, got %s/%s", rid, dec)
}
s := m.session("s1")
if s == nil || len(s.PendingQueue) != 1 || s.Pending.RequestID != "req-2" {
t.Fatalf("resolving the front gate should advance to req-2, got %+v", s.PendingQueue)
}
// A server-side resolve of the remaining gate empties the queue and exits approval.
m.applyServer(msg(protocol.TypeApprovalResolved, func(mm *protocol.ServerMessage) {
mm.RequestID, mm.Outcome = "req-2", "APPROVED"
}))
if s := m.session("s1"); s == nil || s.Pending != nil || len(s.PendingQueue) != 0 {
t.Fatalf("server resolve should clear the last gate")
}
if m.displayState() != StateInSession {
t.Fatalf("empty queue should leave approval state, got %v", m.displayState())
}
}
func TestApprovalServerResolveRemovesSpecificGate(t *testing.T) {
// A resolve for the *back* gate must drop only that one and keep the front shown.
m, _ := approvalModel(apprReq("req-1", "T1"), apprReq("req-2", "T1"))
m.applyServer(msg(protocol.TypeApprovalResolved, func(mm *protocol.ServerMessage) {
mm.RequestID, mm.Outcome = "req-2", "APPROVED"
}))
s := m.session("s1")
if s == nil || len(s.PendingQueue) != 1 || s.Pending.RequestID != "req-1" {
t.Fatalf("resolving req-2 should leave req-1 shown, got %+v", s.PendingQueue)
}
}
func TestApprovalRejectAndSteerRoute(t *testing.T) {
// Reject is single-key for a low tier.
m, client := approvalModel(apprReq("req-1", "T1"))
m = applyKey(m, runeKey('n'))
if rid, dec := decodeApproval(t, client); rid != "req-1" || dec != "REJECT" {
t.Fatalf("want reject req-1, got %s/%s", rid, dec)
}
// Steer enters the existing note-input path (no decision sent yet).
m2, client2 := approvalModel(apprReq("req-2", "T1"))
m2 = applyKey(m2, runeKey('e'))
if !m2.steering || m2.editMode != ModeInsert {
t.Fatalf("steer key should enter the steering-note input path")
}
noApprovalSent(t, client2)
// Typing a note then Enter approves with the note riding along.
for _, r := range "use sudo" {
m2 = applyKey(m2, runeKey(r))
}
m2 = applyKey(m2, keyMsg{Type: keyEnter})
frames := client2.Drain()
var note string
for _, f := range frames {
var raw map[string]any
_ = json.Unmarshal(f, &raw)
if raw["type"] == "com.correx.apps.server.protocol.ClientMessage.ApprovalResponse" {
note, _ = raw["steeringNote"].(string)
if raw["decision"] != "APPROVE" {
t.Fatalf("steer+enter should approve, got %v", raw["decision"])
}
}
}
if note != "use sudo" {
t.Fatalf("steering note should ride along with the decision, got %q", note)
}
}
+16 -16
View File
@@ -3,8 +3,8 @@ package app
import (
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/correx/tui-go/internal/protocol"
)
@@ -92,7 +92,7 @@ func (m Model) clarAnswerValue(i int, q ClarQuestion) string {
// --- key handling ---
func (m Model) handleClarificationKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
func (m Model) handleClarificationKey(k keyMsg) (tea.Model, tea.Cmd) {
s := m.session(m.selectedID)
if s == nil || s.Clar == nil {
return m, nil
@@ -102,21 +102,21 @@ func (m Model) handleClarificationKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
return m.handleClarTyping(k), nil
}
switch k.Type {
case tea.KeyEsc:
case keyEsc:
m.clarDismissed = true
case tea.KeyUp:
case keyUp:
m.clarFocusMove(-1, qs)
case tea.KeyDown:
case keyDown:
m.clarFocusMove(1, qs)
case tea.KeyLeft:
case keyLeft:
m.clarCursorMove(-1, qs)
case tea.KeyRight:
case keyRight:
m.clarCursorMove(1, qs)
case tea.KeySpace:
case keySpace:
m.clarSelect(qs)
case tea.KeyEnter:
case keyEnter:
return m.clarSubmit()
case tea.KeyRunes:
case keyRunes:
switch string(k.Runes) {
case "k":
m.clarFocusMove(-1, qs)
@@ -136,24 +136,24 @@ func (m Model) handleClarificationKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil
}
func (m Model) handleClarTyping(k tea.KeyMsg) tea.Model {
func (m Model) handleClarTyping(k keyMsg) tea.Model {
if m.clarFocus >= len(m.clarText) {
return m
}
switch k.Type {
case tea.KeyEsc:
case keyEsc:
m.clarTyping = false
case tea.KeyEnter:
case keyEnter:
m.clarTyping = false
// committing custom text supersedes any chosen option for this question
if m.clarFocus < len(m.clarChosen) {
m.clarChosen[m.clarFocus] = map[int]bool{}
}
case tea.KeyBackspace:
case keyBackspace:
if t := m.clarText[m.clarFocus]; len(t) > 0 {
m.clarText[m.clarFocus] = t[:len(t)-1]
}
case tea.KeyRunes, tea.KeySpace:
case keyRunes, keySpace:
m.clarText[m.clarFocus] += string(k.Runes)
}
return m
+9 -10
View File
@@ -4,7 +4,6 @@ import (
"strings"
"testing"
tea "github.com/charmbracelet/bubbletea"
"github.com/correx/tui-go/internal/protocol"
"github.com/correx/tui-go/internal/ws"
)
@@ -43,13 +42,13 @@ func TestClarificationEntersStateAndRenders(t *testing.T) {
func TestClarificationSelectAndSubmit(t *testing.T) {
m := clarModel()
// move the option cursor to "Vue" (index 1) and select it
m = applyClarKey(m, tea.KeyMsg{Type: tea.KeyRight})
m = applyClarKey(m, tea.KeyMsg{Type: tea.KeySpace})
m = applyClarKey(m, keyMsg{Type: keyRight})
m = applyClarKey(m, keyMsg{Type: keySpace})
if !m.clarChosenAt(0, 1) {
t.Fatalf("expected option 1 chosen, chosen=%v", m.clarChosen)
}
// submit
m = applyClarKey(m, tea.KeyMsg{Type: tea.KeyEnter})
m = applyClarKey(m, keyMsg{Type: keyEnter})
if s := m.session("s1"); s == nil || s.Clar != nil {
t.Fatalf("expected clarification cleared after submit")
}
@@ -68,8 +67,8 @@ func TestClarificationSubmitGuardWaitsForAllAnswers(t *testing.T) {
},
})
// answer only the first question, then attempt submit
m = applyClarKey(m, tea.KeyMsg{Type: tea.KeySpace})
m = applyClarKey(m, tea.KeyMsg{Type: tea.KeyEnter})
m = applyClarKey(m, keyMsg{Type: keySpace})
m = applyClarKey(m, keyMsg{Type: keyEnter})
if s := m.session("s1"); s == nil || s.Clar == nil {
t.Fatalf("expected clarification to remain with partial answers")
}
@@ -80,14 +79,14 @@ func TestClarificationSubmitGuardWaitsForAllAnswers(t *testing.T) {
func TestClarificationCustomTextAnswer(t *testing.T) {
m := clarModel()
m = applyClarKey(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("e")})
m = applyClarKey(m, keyMsg{Type: keyRunes, Runes: []rune("e")})
if !m.clarTyping {
t.Fatalf("expected typing mode after 'e'")
}
for _, r := range "Solid" {
m = applyClarKey(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
m = applyClarKey(m, keyMsg{Type: keyRunes, Runes: []rune{r}})
}
m = applyClarKey(m, tea.KeyMsg{Type: tea.KeyEnter})
m = applyClarKey(m, keyMsg{Type: keyEnter})
if m.clarTyping {
t.Fatalf("expected typing committed")
}
@@ -99,7 +98,7 @@ func TestClarificationCustomTextAnswer(t *testing.T) {
}
}
func applyClarKey(m Model, k tea.KeyMsg) Model {
func applyClarKey(m Model, k keyMsg) Model {
updated, _ := m.handleClarificationKey(k)
return updated.(Model)
}
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"sort"
"strings"
"github.com/charmbracelet/lipgloss"
"charm.land/lipgloss/v2"
)
// cmdcard.go is layer 1 of tui-requirements §5 — the deterministic command-approval
+11 -11
View File
@@ -3,8 +3,8 @@ package app
import (
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/correx/tui-go/internal/protocol"
)
@@ -23,40 +23,40 @@ func (m *Model) openConfig() {
// handleConfigKey owns every key while the config overlay is open. In edit mode it captures the
// value being typed; otherwise it navigates fields, stages edits, and saves.
func (m Model) handleConfigKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
func (m Model) handleConfigKey(k keyMsg) (tea.Model, tea.Cmd) {
if m.configEditing {
switch k.Type {
case tea.KeyEsc:
case keyEsc:
m.configEditing = false
m.configEditBuf = ""
case tea.KeyEnter:
case keyEnter:
if m.configIndex >= 0 && m.configIndex < len(m.configFields) {
m.configStaged[m.configFields[m.configIndex].Key] = m.configEditBuf
}
m.configEditing = false
m.configEditBuf = ""
case tea.KeyBackspace:
case keyBackspace:
if n := len(m.configEditBuf); n > 0 {
m.configEditBuf = m.configEditBuf[:n-1]
}
case tea.KeyRunes, tea.KeySpace:
case keyRunes, keySpace:
m.configEditBuf += string(k.Runes)
}
return m, nil
}
switch {
case k.Type == tea.KeyEsc || runeIs(k, "g"):
case k.Type == keyEsc || runeIs(k, "g"):
m.overlay = OverlayNone
case k.Type == tea.KeyUp || runeIs(k, "k"):
case k.Type == keyUp || runeIs(k, "k"):
if m.configIndex > 0 {
m.configIndex--
}
case k.Type == tea.KeyDown || runeIs(k, "j"):
case k.Type == keyDown || runeIs(k, "j"):
if m.configIndex < len(m.configFields)-1 {
m.configIndex++
}
case k.Type == tea.KeyEnter || k.Type == tea.KeySpace:
case k.Type == keyEnter || k.Type == keySpace:
m = m.actOnConfigField()
case runeIs(k, "s"):
if len(m.configStaged) > 0 {
@@ -4,7 +4,6 @@ import (
"strings"
"testing"
tea "github.com/charmbracelet/bubbletea"
"github.com/correx/tui-go/internal/protocol"
)
@@ -23,7 +22,7 @@ func configModel() Model {
func TestConfigToggleBoolStagesFlippedValue(t *testing.T) {
m := configModel()
updated, _ := m.handleConfigKey(tea.KeyMsg{Type: tea.KeyEnter})
updated, _ := m.handleConfigKey(keyMsg{Type: keyEnter})
m = updated.(Model)
if m.configStaged["project.enabled"] != "true" {
t.Fatalf("want staged project.enabled=true, got %q", m.configStaged["project.enabled"])
@@ -40,7 +39,7 @@ func TestConfigToggleBoolStagesFlippedValue(t *testing.T) {
func TestConfigEnumCycles(t *testing.T) {
m := configModel()
m.configIndex = 1 // tui.theme
updated, _ := m.handleConfigKey(tea.KeyMsg{Type: tea.KeyEnter})
updated, _ := m.handleConfigKey(keyMsg{Type: keyEnter})
m = updated.(Model)
if m.configStaged["tui.theme"] != "light" {
t.Fatalf("want staged tui.theme=light (next after dark), got %q", m.configStaged["tui.theme"])
@@ -51,7 +50,7 @@ func TestConfigIntEditCommitsBuffer(t *testing.T) {
m := configModel()
m.configIndex = 2 // router.generation.max_tokens
// Enter edit mode.
updated, _ := m.handleConfigKey(tea.KeyMsg{Type: tea.KeyEnter})
updated, _ := m.handleConfigKey(keyMsg{Type: keyEnter})
m = updated.(Model)
if !m.configEditing {
t.Fatalf("expected edit mode after enter on INT field")
@@ -59,10 +58,10 @@ func TestConfigIntEditCommitsBuffer(t *testing.T) {
// Clear the seeded buffer and type a new value.
m.configEditBuf = ""
for _, r := range "1024" {
updated, _ = m.handleConfigKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
updated, _ = m.handleConfigKey(keyMsg{Type: keyRunes, Runes: []rune{r}})
m = updated.(Model)
}
updated, _ = m.handleConfigKey(tea.KeyMsg{Type: tea.KeyEnter})
updated, _ = m.handleConfigKey(keyMsg{Type: keyEnter})
m = updated.(Model)
if m.configEditing {
t.Fatalf("expected edit mode to end after commit")
+83
View File
@@ -0,0 +1,83 @@
package app
import (
"strings"
"testing"
)
// splitCursor must report the caret's display column/row from a composited frame
// (ANSI ignored) and swap the marker for the replacement glyph.
func TestSplitCursorCoords(t *testing.T) {
// marker sits after 3 visible cells ("a", "b", "c") on the second line.
raw := "first line\n" + "ab\x1b[38;2;1;2;3mc\x1b[m" + cursorMarker + "d"
clean, cur := splitCursor(raw, " ")
if cur == nil {
t.Fatal("expected a cursor when the marker is present")
}
if cur.X != 3 || cur.Y != 1 {
t.Fatalf("cursor at (%d,%d), want (3,1)", cur.X, cur.Y)
}
if strings.Contains(clean, cursorMarker) {
t.Error("marker must be stripped from the returned frame")
}
if strings.Count(clean, " ") == 0 {
t.Error("marker should have been replaced by the repl glyph")
}
}
// No marker (e.g. normal mode) → no cursor, frame unchanged.
func TestSplitCursorAbsent(t *testing.T) {
raw := "no caret here"
clean, cur := splitCursor(raw, "▏")
if cur != nil {
t.Error("no marker must yield a nil cursor (hidden)")
}
if clean != raw {
t.Error("frame must be unchanged when no marker is present")
}
}
// The live View hides the cursor in normal mode and shows a real one in insert
// mode; the marker must never leak into the rendered content either way.
func TestViewCursorByMode(t *testing.T) {
m := inSessionModel(120, 40)
m.editMode = ModeNormal
v := m.View()
if v.Cursor != nil {
t.Error("normal mode must hide the cursor (nil)")
}
if strings.Contains(v.Content, cursorMarker) {
t.Error("content must not contain the raw cursor marker")
}
m.editMode = ModeInsert
m.inputMode = ModeRouter
m.inputBuffer = "hello"
m.inputCursor = len(m.inputBuffer)
v = m.View()
if v.Cursor == nil {
t.Fatal("insert mode must show a real cursor")
}
if v.Cursor.Y < 0 || v.Cursor.Y >= m.height || v.Cursor.X < 0 {
t.Errorf("cursor (%d,%d) out of frame bounds", v.Cursor.X, v.Cursor.Y)
}
if strings.Contains(v.Content, cursorMarker) {
t.Error("content must not contain the raw cursor marker")
}
// The static render keeps a visible glyph and also strips the marker.
if strings.Contains(m.render(), cursorMarker) {
t.Error("render() must strip the marker")
}
// An open overlay keeps editMode==Insert (so closing resumes typing) but the
// composer must not draw a live cursor behind the modal.
m.overlay = OverlayPalette
if v = m.View(); v.Cursor != nil {
t.Error("an open overlay must suppress the composer cursor")
}
if strings.Contains(v.Content, cursorMarker) {
t.Error("content must not contain the marker with an overlay open")
}
}
+156 -7
View File
@@ -22,6 +22,43 @@ func PreviewFrame(kind string, w, h int) string {
m.bgUpdates = 3
m.selectedID = "04a546aa"
case "changes":
m.connected = true
m.currentModel = "llama-cpp:default"
m.sessions = sampleSessions()
m.selectedID = "04a546aa"
m.sessionEntered = true
m.rightPanel = 1
if s := m.session("04a546aa"); s != nil {
s.CurrentStage = "write_script"
}
m.routerMessages["04a546aa"] = []RouterEntry{
{Role: "user", Content: "add a healthcheck script and document it"},
{Role: "router", Content: "I'll write the script.", Metrics: &TurnMetrics{LatencyMs: 1200, TotalTokens: 340}},
{Role: "tool", Content: "--- a/healthcheck.sh\n+++ b/healthcheck.sh\n@@ -0,0 +1,4 @@\n+#!/usr/bin/env bash\n+curl -sf localhost:8080/health\n+echo ok\n+exit 0\n"},
{Role: "router", Content: "Now the docs.", Metrics: &TurnMetrics{LatencyMs: 820, TotalTokens: 150}},
{Role: "tool", Content: "--- a/README.md\n+++ b/README.md\n@@ -1,1 +1,2 @@\n existing line\n+## Health\n"},
}
case "compose":
m.connected = true
m.currentModel = "llama-cpp:default"
m.sessions = sampleSessions()
m.editMode = ModeInsert
m.inputMode = ModeRouter
m.inputBuffer = "fix the healthcheck script\nso it retries three times\nbefore it fails"
m.inputCursor = len(m.inputBuffer)
case "resume":
m.connected = true
m.currentModel = "llama-cpp:default"
m.overlay = OverlaySessions
m.sessionList = []SessionSummary{
{SessionID: "04a546aa8b2c", Status: "ACTIVE", WorkflowID: "healthcheck", StageCount: 3, LastActivityAt: "2026-06-22T14:30:05Z"},
{SessionID: "0d7097bb1f3e", Status: "COMPLETED", WorkflowID: "healthcheck", StageCount: 5, LastActivityAt: "2026-06-21T09:15:00Z"},
{SessionID: "1dae17cc77aa", Status: "FAILED", WorkflowID: "chat", LastActivityAt: "2025-12-30T18:02:00Z"},
}
case "workflows":
m.connected = true
m.currentModel = "llama-cpp:default"
@@ -78,14 +115,14 @@ func PreviewFrame(kind string, w, h int) string {
s.CurrentStage = "write_script"
s.WorkspaceRoot = "/home/kami/Programs/correx"
s.Events = sampleEvents()
s.Pending = &Approval{
s.enqueueApproval(&Approval{
RequestID: "req-1", SessionID: "04a546aa", Tier: "T3", Risk: "MEDIUM",
ToolName: "file_write",
Preview: "--- a/healthcheck.sh\n+++ b/healthcheck.sh\n@@ -0,0 +1,4 @@\n+#!/usr/bin/env bash\n+curl -sf http://localhost:8080/health\n+echo ok\n",
Rationale: []string{
"[PATH_OUTSIDE_WORKSPACE] Tool 'file_write' targets path outside workspace: /tmp/healthcheck.sh",
},
}
})
}
case "approval-steer":
@@ -97,11 +134,11 @@ func PreviewFrame(kind string, w, h int) string {
m.steerBuffer = "also print the current distro and kernel version"
if s := m.session("04a546aa"); s != nil {
s.CurrentStage = "write_script"
s.Pending = &Approval{
s.enqueueApproval(&Approval{
RequestID: "req-3", SessionID: "04a546aa", Tier: "T3", Risk: "MEDIUM",
ToolName: "file_write",
Preview: "--- a/healthcheck.sh\n+++ b/healthcheck.sh\n@@ -0,0 +1,2 @@\n+#!/usr/bin/env bash\n+echo ok\n",
}
})
}
case "approval-shell":
@@ -114,14 +151,14 @@ func PreviewFrame(kind string, w, h int) string {
s.CurrentStage = "execute_script"
s.Events = sampleEvents()
s.WorkspaceRoot = "/home/kami/Programs/correx"
s.Pending = &Approval{
s.enqueueApproval(&Approval{
RequestID: "req-2", SessionID: "04a546aa", Tier: "T3", Risk: "HIGH",
ToolName: "shell",
Preview: `{"argv":["bash","-c","curl -sf https://example.com/install.sh | sudo sh"]}`,
Rationale: []string{
"[INTERPRETER_EXECUTION] Tool 'shell' invokes interpreter 'bash'",
},
}
})
}
case "diff":
@@ -160,8 +197,120 @@ func PreviewFrame(kind string, w, h int) string {
m.selectedID = "04a546aa"
m.overlay = OverlayHealth
m.health = sampleHealth()
case "grant-scope":
m.connected = true
m.currentModel = "llama-cpp:default"
m.sessions = sampleSessions()
m.selectedID = "04a546aa"
m.sessionEntered = true
m.grantFor = &Approval{RequestID: "req-1", SessionID: "04a546aa", Tier: "T3", ToolName: "file_write"}
m.grantScopeIndex = 1
m.overlay = OverlayGrantScope
case "actions":
m.connected = true
m.currentModel = "llama-cpp:default"
m.sessions = sampleSessions()
m.selectedID = "04a546aa"
m.sessionEntered = true
m.routerConnected = true
m.routerMessages["04a546aa"] = []RouterEntry{
{Role: "user", Content: "run the healthcheck and write the script"},
{Role: "router", Content: "I'll create the script, then run it."},
{Role: "action", Icon: "✎", Content: "wrote healthcheck.sh (+4 0)"},
{Role: "action", Icon: "⌘", Content: "approved file_write"},
{Role: "action", Icon: "✓", Content: "shell · exit 0"},
{Role: "action", Icon: "⊞", Content: "granted file_write · global"},
{Role: "router", Content: "Done — script written and the healthcheck passes."},
}
if s := m.session("04a546aa"); s != nil {
s.CurrentStage = "execute_script"
s.LastEventAt = nowMillis() - 3000
}
case "help":
m.connected = true
m.currentModel = "llama-cpp:default"
m.sessions = sampleSessions()
m.selectedID = "04a546aa"
m.sessionEntered = true
m.overlay = OverlayHelp
case "events-filter":
m.connected = true
m.currentModel = "llama-cpp:default"
m.sessions = sampleSessions()
m.selectedID = "04a546aa"
m.sessionEntered = true
if s := m.session("04a546aa"); s != nil {
s.Events = sampleEvents()
}
m.overlay = OverlayEventInspector
m.eventFilter = "tool"
m.eventFilterTyping = true
case "grants":
m.connected = true
m.currentModel = "llama-cpp:default"
m.sessions = sampleSessions()
m.selectedID = "04a546aa"
m.sessionEntered = true
m.grants = []protocol.GrantDto{
{GrantID: "g-1", Scope: "GLOBAL", ToolName: "read_file", Tiers: []string{"T1", "T2"}},
{GrantID: "g-2", Scope: "PROJECT", ToolName: "file_write", Tiers: []string{"T3"}, ProjectID: "/home/kami/Programs/correx"},
{GrantID: "g-3", Scope: "GLOBAL", ToolName: "shell", Tiers: []string{"T3", "T4"}},
}
m.grantIndex = 1
m.overlay = OverlayGrants
case "tasks", "task-detail":
m.connected = true
m.currentModel = "llama-cpp:default"
m.sessions = sampleSessions()
m.selectedID = "04a546aa"
m.overlay = OverlayTasks
m.taskList = sampleTaskBoard()
if kind == "task-detail" {
m.taskDetail = true
m.taskListIndex = 2 // a blocked child, to show the "waiting on" line
}
}
return m.render()
}
// sampleTaskBoard is a decomposed work graph: an epic waiting on its three
// children, one of which (webui-2) is ready now, plus an in-flight and a done
// task — exercising every readiness glyph and status color.
func sampleTaskBoard() []TaskSummary {
return []TaskSummary{
{
ID: "webui-1", Key: "webui-1", Status: "TODO", Title: "Frontend web UI for correx",
Goal: "operator can drive sessions from a browser",
BlockedBy: []string{"webui-2", "webui-3", "webui-4"},
Links: []TaskLinkWire{{TargetID: "webui-2", Type: "DEPENDS_ON", TargetKind: "TASK"}},
},
{
ID: "webui-2", Key: "webui-2", Status: "TODO", Title: "Scaffold the Vite + React app",
Goal: "buildable shell with routing", Ready: true,
AcceptanceCriteria: []string{"npm run build passes", "lands under apps/web"},
AffectedPaths: []string{"apps/web/"},
},
{
ID: "webui-3", Key: "webui-3", Status: "TODO", Title: "Auth / token entry view",
Goal: "operator pastes a token and connects", BlockedBy: []string{"webui-2"},
Links: []TaskLinkWire{{TargetID: "webui-1", Type: "IMPLEMENTS", TargetKind: "TASK"}},
},
{
ID: "webui-4", Key: "webui-4", Status: "TODO", Title: "Session board view",
Goal: "live roster mirroring the TUI", BlockedBy: []string{"webui-2"},
},
{
ID: "api-7", Key: "api-7", Status: "IN_PROGRESS", Title: "CORS + token endpoint",
Goal: "server accepts browser origins", Claimant: "04a546aa8b2c",
},
{ID: "api-3", Key: "api-3", Status: "DONE", Title: "Stable /sessions JSON shape"},
}
return m.View()
}
func sampleStats() *protocol.StatsDto {
+6 -2
View File
@@ -1,10 +1,11 @@
package app
import (
"image/color"
"strconv"
"strings"
"github.com/charmbracelet/lipgloss"
"charm.land/lipgloss/v2"
)
type diffKind int
@@ -53,6 +54,9 @@ func parseUnifiedDiff(diff string) []diffRow {
}
for _, ln := range strings.Split(strings.TrimRight(diff, "\n"), "\n") {
switch {
// File headers carry a trailing space ("+++ b/x"); requiring it means a real
// changed line whose content starts with "++"/"--" isn't mistaken for a header
// and silently dropped from the rendered diff (and its row count).
case strings.HasPrefix(ln, "+++ "), strings.HasPrefix(ln, "--- "),
strings.HasPrefix(ln, "diff "), strings.HasPrefix(ln, "index "):
continue
@@ -193,7 +197,7 @@ func (m Model) diffCell(num int, text string, kind diffKind, w int) string {
if kind == diffBlank {
return lipgloss.NewStyle().Background(t.P.Bg).Render(strings.Repeat(" ", w))
}
var bg, textFg, signFg lipgloss.Color
var bg, textFg, signFg color.Color
sign := " "
switch kind {
case diffAdd:
@@ -0,0 +1,46 @@
package app
import (
"strings"
"testing"
)
// scrollDiff must clamp to [0, diffMaxScroll] against the same geometry the modal renders to,
// so PgUp/PgDn (and g/G) past either end land exactly on the edge — no dead presses unwinding
// an overshoot. Backs the fullscreen viewer for every tool preview (diff / command / plain).
func TestDiffScrollClampsToBounds(t *testing.T) {
m := inSessionModel(120, 30)
var b strings.Builder
b.WriteString("--- a/f\n+++ b/f\n@@ -0,0 +1,40 @@\n")
for i := 0; i < 40; i++ {
b.WriteString("+a new line\n")
}
// currentDiff() falls back to the last "tool" transcript entry when no approval is pending.
m.routerMessages[m.selectedID] = []RouterEntry{{Role: "tool", Content: b.String()}}
max := m.diffMaxScroll()
if max <= 0 {
t.Fatalf("fixture diff must exceed the modal body height to test scrolling; max=%d", max)
}
m.scrollDiff(10_000) // way past the bottom
if m.diffScrollOffset != max {
t.Fatalf("scroll past end clamped to %d, want max %d", m.diffScrollOffset, max)
}
m.scrollDiff(-10_000) // way past the top
if m.diffScrollOffset != 0 {
t.Fatalf("scroll past top clamped to %d, want 0", m.diffScrollOffset)
}
}
// A diff that fits the modal body never scrolls.
func TestDiffScrollNoOpWhenFits(t *testing.T) {
m := inSessionModel(120, 30)
m.routerMessages[m.selectedID] = []RouterEntry{
{Role: "tool", Content: "--- a/f\n+++ b/f\n@@ -1,1 +1,1 @@\n-x\n+y\n"},
}
m.scrollDiff(5)
if m.diffScrollOffset != 0 {
t.Fatalf("short diff should not scroll, got %d", m.diffScrollOffset)
}
}
@@ -0,0 +1,38 @@
package app
import (
"strings"
"testing"
"github.com/charmbracelet/x/ansi"
)
// editorLines splits the buffer on newlines into one display row each, in order.
func TestEditorLinesSplitsOnNewline(t *testing.T) {
m := NewModel(nil)
m.theme = NewTheme(SoftBlue)
m.inputBuffer = "one\ntwo\nthree"
m.inputCursor = len(m.inputBuffer)
lines := m.editorLines()
if len(lines) != 3 {
t.Fatalf("want 3 lines, got %d", len(lines))
}
for i, want := range []string{"one", "two", "three"} {
if got := strings.TrimRight(ansi.Strip(lines[i]), " ▏"); got != want {
t.Errorf("line %d = %q, want %q", i, got, want)
}
}
}
// A buffer taller than maxInputLines shows only the last maxInputLines rows (cursor tail).
func TestEditorLinesCapsHeight(t *testing.T) {
m := NewModel(nil)
m.theme = NewTheme(SoftBlue)
m.inputBuffer = strings.Repeat("x\n", maxInputLines+4) + "last"
m.inputCursor = len(m.inputBuffer)
if got := len(m.editorLines()); got != maxInputLines {
t.Fatalf("want capped at %d lines, got %d", maxInputLines, got)
}
}
@@ -0,0 +1,33 @@
package app
import (
"strings"
"testing"
)
// The event-stream badge must track the selected session's lifecycle, not just the socket:
// a terminal/paused session emits no more events, so it must not keep reading "● live".
func TestEventStreamBadgeReflectsSessionStatus(t *testing.T) {
cases := []struct{ status, want string }{
{"ACTIVE", "● live"},
{"COMPLETED", "✓ done"},
{"FAILED", "✗ failed"},
{"PAUSED awaiting approval", "‖ paused"},
}
for _, c := range cases {
m := inSessionModel(120, 40)
m.connected = true
m.sessions[0].Status = c.status
if got := m.eventStreamBadge(); !strings.Contains(got, c.want) {
t.Errorf("status %q: badge = %q, want substring %q", c.status, got, c.want)
}
}
// Disconnected always reads idle, regardless of the session's status.
m := inSessionModel(120, 40)
m.connected = false
m.sessions[0].Status = "ACTIVE"
if got := m.eventStreamBadge(); !strings.Contains(got, "○ idle") {
t.Errorf("disconnected: badge = %q, want idle", got)
}
}
@@ -0,0 +1,62 @@
package app
import "testing"
func TestAtTokenBoundary(t *testing.T) {
cases := []struct {
buf string
cur int
want bool
}{
{"", 0, true}, // start of empty line
{"see ", 4, true}, // just after a space
{"email", 5, false}, // mid-word (e.g. user@host)
{"a b", 2, true}, // after the space
{"a b", 3, false}, // after 'b'
}
for _, c := range cases {
m := NewModel(nil)
m.inputBuffer = c.buf
m.inputCursor = c.cur
if got := m.atTokenBoundary(); got != c.want {
t.Errorf("atTokenBoundary(%q,@%d) = %v, want %v", c.buf, c.cur, got, c.want)
}
}
}
// `@` consumes itself to open the picker; selecting a file inserts `@path ` at the cursor.
func TestInsertFileRef(t *testing.T) {
m := NewModel(nil)
m.inputBuffer = "look at "
m.inputCursor = len(m.inputBuffer)
m.insertFileRef("src/main.go")
if want := "look at @src/main.go "; m.inputBuffer != want {
t.Fatalf("buffer = %q, want %q", m.inputBuffer, want)
}
if m.inputCursor != len(m.inputBuffer) {
t.Fatalf("cursor = %d, want %d", m.inputCursor, len(m.inputBuffer))
}
}
func TestFilteredFiles(t *testing.T) {
m := NewModel(nil)
m.files = []string{"cmd/main.go", "internal/app/view.go", "README.md", "internal/app/model.go"}
m.fileFilter = ""
if len(m.filteredFiles()) != 4 {
t.Fatalf("empty filter should pass all 4, got %d", len(m.filteredFiles()))
}
m.fileFilter = "app/"
got := m.filteredFiles()
if len(got) != 2 {
t.Fatalf("filter %q matched %d, want 2 (%v)", m.fileFilter, len(got), got)
}
m.fileFilter = "README"
if got := m.filteredFiles(); len(got) != 1 || got[0] != "README.md" {
t.Fatalf("filter README = %v, want [README.md]", got)
}
}
@@ -0,0 +1,62 @@
package app
import (
"strings"
"testing"
)
// Every key the TUI binds (normal mode, approval band, and the diff/preview viewer) must
// have a row in the ? overlay. Keyed by a distinctive description phrase so deleting a row
// fails loudly here — keep this list in step with handleNormalKey / handleApprovalKey and
// the OverlayDiff handler whenever a binding is added or changed.
func TestHelpCoversEveryKeybind(t *testing.T) {
m := inSessionModel(120, 80)
help := strings.Join(m.helpBody(), "\n")
want := []string{
// navigate
"move the session / list selection", // ↑↓ / j k
"open the selected session", // enter
"cycle launch target", // Tab / w
"filter the session list", // /
"jump to your previous / next message", // ^↑ / ^↓
"scroll output", // PgUp / PgDn (+ ^u / ^d)
"drop selection", // esc
"back to the session list", // l
// session
"compose a message", // i
"right panel: events / changes", // d
"toggle chat / steering mode", // s
"show workflows", // w
"copy the selected message", // y
"open the latest diff / preview", // ^x
"cancel the running session", // c
"re-open a dismissed approval gate", // a
"quit", // q
// overlays
"command palette", // p
"this keybindings help", // ?
"event inspector", // e
"tools / artifacts", // t / v
"resume past sessions", // S / R
"swap model / edit config", // m / g
"grants / idea board", // G / I
// approval band
"approve once", // y / a / enter
"reject", // n / r
"steer (add a note)", // e / s
"approve-always", // A
"walk the pending queue", // ↑ / ↓
"fullscreen the diff / command preview", // ^x
"dismiss for now", // esc
// diff / preview viewer
"scroll a line", // ↑ / ↓
"scroll a page", // PgUp / PgDn
"jump to top / end", // g / G
}
for _, w := range want {
if !strings.Contains(help, w) {
t.Errorf("? help is missing a row for %q", w)
}
}
}
+1 -1
View File
@@ -3,7 +3,7 @@ package app
import (
"strings"
"github.com/charmbracelet/lipgloss"
"charm.land/lipgloss/v2"
)
// ideacard.go renders the cross-session idea board (OverlayIdeas): the ideas the router
+1 -2
View File
@@ -5,7 +5,6 @@ import (
"strings"
"testing"
tea "github.com/charmbracelet/bubbletea"
"github.com/correx/tui-go/internal/protocol"
"github.com/correx/tui-go/internal/ws"
)
@@ -43,7 +42,7 @@ func TestIdeaRemoveSendsDiscardAndDropsRow(t *testing.T) {
m, client := ideaModel()
_ = client.Drain() // drop the ListIdeas frame from openIdeas
updated, _ := m.handleOverlayKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("x")})
updated, _ := m.handleOverlayKey(keyMsg{Type: keyRunes, Runes: []rune("x")})
m = updated.(Model)
if len(m.ideas) != 1 || m.ideas[0].IdeaID != "i2" {
@@ -0,0 +1,101 @@
package app
import "testing"
// History is a single global ring shared by every input surface (free-chat, the
// workflow-intent line, in-session chat), so ↑/↓ recalls anything you've sent — including
// outside a session, which the per-session map could not do.
func TestRecordInputGlobalRing(t *testing.T) {
m := NewModel(nil)
m.recordInput("first")
m.recordInput(" second ") // trimmed
m.recordInput("second") // immediate dup ignored
m.recordInput("") // blank ignored
m.recordInput(" ") // blank-after-trim ignored
want := []string{"first", "second"}
if len(m.inputHistory) != len(want) {
t.Fatalf("inputHistory = %q, want %q", m.inputHistory, want)
}
for i := range want {
if m.inputHistory[i] != want[i] {
t.Fatalf("inputHistory[%d] = %q, want %q", i, m.inputHistory[i], want[i])
}
}
}
func TestRecordInputCaps(t *testing.T) {
m := NewModel(nil)
for i := 0; i < 250; i++ {
m.recordInput(string(rune('a'+i%26)) + itoa(i))
}
if len(m.inputHistory) != 100 {
t.Fatalf("ring len = %d, want 100 (capped)", len(m.inputHistory))
}
}
// (itoa is defined in view.go and reused here for the cap test.)
// ↑/↓ walks the ring with no session selected (selectedID == ""), proving history
// recall works in free-chat / intent contexts, not just in-session.
func TestHistoryWalkWithoutSession(t *testing.T) {
m := NewModel(nil)
m.recordInput("one")
m.recordInput("two")
m.inputBuffer = "draft"
m.historyPrev() // newest first
if m.inputBuffer != "two" {
t.Fatalf("prev#1 = %q, want %q", m.inputBuffer, "two")
}
m.historyPrev()
if m.inputBuffer != "one" {
t.Fatalf("prev#2 = %q, want %q", m.inputBuffer, "one")
}
m.historyPrev() // clamp at oldest
if m.inputBuffer != "one" {
t.Fatalf("prev#3 (clamp) = %q, want %q", m.inputBuffer, "one")
}
m.historyNext()
if m.inputBuffer != "two" {
t.Fatalf("next#1 = %q, want %q", m.inputBuffer, "two")
}
m.historyNext() // back to the stashed live draft
if m.inputBuffer != "draft" {
t.Fatalf("next#2 = %q, want restored draft %q", m.inputBuffer, "draft")
}
}
// (animating + caps tests below.)
// animating() gates the redraw tick: it must be FALSE on an idle screen (normal mode,
// no active session) so terminal selection survives, and TRUE while typing or spinning.
func TestAnimatingGatesIdleRedraw(t *testing.T) {
m := NewModel(nil)
m.editMode = ModeNormal
if m.animating() {
t.Fatal("idle (normal mode, no active session) must NOT animate — else selection is wiped")
}
// Insert mode must NOT force the frame loop anymore: the composer caret is a real
// terminal cursor (View.Cursor) that the terminal blinks itself, so an idle insert
// screen holds still (and native text selection survives).
m.editMode = ModeInsert
if m.animating() {
t.Fatal("insert mode must NOT animate — the native cursor blinks itself")
}
m.editMode = ModeNormal
// Other typing surfaces still draw their own carets and keep ticking.
m.steering = true
if !m.animating() {
t.Fatal("steering (drawn caret) must animate")
}
m.steering = false
m.sessions = []Session{{ID: "s1", Active: true}}
if !m.animating() {
t.Fatal("an active session must animate (spinner)")
}
}
+123
View File
@@ -0,0 +1,123 @@
package app
import tea "charm.land/bubbletea/v2"
// bubbletea v2 collapsed key events into a single (Code rune, Mod KeyMod) form
// and dropped the v1 KeyType enum the handlers were written against. Rather than
// rewrite ~190 key-match sites, this shim adapts a v2 key press into the same
// v1-shaped struct (Type + Runes + modifier flags), so all the matching logic
// downstream stays untouched. The only place that ever touches the raw v2 key
// is toKeyMsg below, called once at the Update boundary.
type keyType int
const (
keyRunes keyType = iota
keyEnter
keyUp
keyDown
keyLeft
keyRight
keySpace
keyEsc
keyBackspace
keyTab
keyPgUp
keyPgDown
keyCtrlA
keyCtrlC
keyCtrlD
keyCtrlJ
keyCtrlR
keyCtrlU
keyCtrlX
keyCtrlUp
keyCtrlDown
keyOther
)
// keyMsg is the v1-shaped key event the handlers consume: a Type plus the typed
// Runes and modifier flags. Shift is newly meaningful under v2's kitty key
// disambiguation — it powers Shift+Enter newlines (see handleInsertKey).
type keyMsg struct {
Type keyType
Runes []rune
Alt bool
Shift bool
str string
}
func (k keyMsg) String() string { return k.str }
// toKeyMsg converts a bubbletea v2 key press into the shim's v1-shaped form.
func toKeyMsg(p tea.KeyPressMsg) keyMsg {
k := p.Key()
out := keyMsg{
Alt: k.Mod.Contains(tea.ModAlt),
Shift: k.Mod.Contains(tea.ModShift),
str: k.Keystroke(),
}
ctrl := k.Mod.Contains(tea.ModCtrl)
switch k.Code {
case tea.KeyEnter:
out.Type = keyEnter
case tea.KeyEscape:
out.Type = keyEsc
case tea.KeyTab:
out.Type = keyTab
case tea.KeyBackspace:
out.Type = keyBackspace
case tea.KeySpace:
out.Type = keySpace
out.Runes = []rune{' '}
case tea.KeyUp:
if ctrl {
out.Type = keyCtrlUp
} else {
out.Type = keyUp
}
case tea.KeyDown:
if ctrl {
out.Type = keyCtrlDown
} else {
out.Type = keyDown
}
case tea.KeyLeft:
out.Type = keyLeft
case tea.KeyRight:
out.Type = keyRight
case tea.KeyPgUp:
out.Type = keyPgUp
case tea.KeyPgDown:
out.Type = keyPgDown
default:
if ctrl {
switch k.Code {
case 'a':
out.Type = keyCtrlA
case 'c':
out.Type = keyCtrlC
case 'd':
out.Type = keyCtrlD
case 'j':
out.Type = keyCtrlJ
case 'r':
out.Type = keyCtrlR
case 'u':
out.Type = keyCtrlU
case 'x':
out.Type = keyCtrlX
default:
out.Type = keyOther
}
return out
}
if k.Text != "" {
out.Type = keyRunes
out.Runes = []rune(k.Text)
} else {
out.Type = keyOther
}
}
return out
}
+76
View File
@@ -0,0 +1,76 @@
package app
import (
"strings"
"sync"
"github.com/charmbracelet/glamour"
)
// defaultMarkdownWidth is the wrap width used when the caller doesn't yet know
// the terminal width (e.g. a zero/negative value). 80 columns is a safe terminal
// default that never blows up glamour's word-wrap.
const defaultMarkdownWidth = 80
// markdownRenderers memoizes one glamour renderer per wrap width. A glamour
// renderer bakes its word-wrap width in at construction, so we key the cache by
// width and build lazily. Constructing a renderer is comparatively expensive
// (it compiles a style + goldmark parser), hence the cache; the TUI renders the
// same handful of widths over its lifetime.
var (
markdownMu sync.Mutex
markdownRenderers = map[int]*glamour.TermRenderer{}
)
// rendererForWidth returns a cached dark-styled glamour renderer wrapped to w,
// or nil if glamour could not build one (caller falls back to plain text).
func rendererForWidth(w int) *glamour.TermRenderer {
markdownMu.Lock()
defer markdownMu.Unlock()
if r, ok := markdownRenderers[w]; ok {
return r
}
// Pin the "dark" standard style so output is deterministic and independent
// of the ambient terminal's background detection (important for tests and
// for headless/over-the-wire sessions).
r, err := glamour.NewTermRenderer(
glamour.WithStandardStyle("dark"),
glamour.WithWordWrap(w),
)
if err != nil {
// Cache the failure as nil so we don't retry-and-fail every frame.
markdownRenderers[w] = nil
return nil
}
markdownRenderers[w] = r
return r
}
// renderMarkdown renders content as terminal markdown (bold, italics, lists,
// headings, code blocks/inline code) word-wrapped to width, styled for a dark
// terminal. It is robust by construction: on any glamour error — or if the
// renderer can't be built — it returns the original plain content rather than
// crashing the TUI. Trailing whitespace glamour appends is trimmed so it doesn't
// disrupt the surrounding layout.
func renderMarkdown(content string, width int) string {
if width < 1 {
width = defaultMarkdownWidth
}
r := rendererForWidth(width)
if r == nil {
return content
}
out, err := r.Render(content)
if err != nil {
return content
}
// glamour pads block output with leading/trailing blank lines; strip them so
// the rendered turn slots into the feed without extra gaps.
out = strings.Trim(out, "\n")
if strings.TrimSpace(out) == "" {
// Nothing survived rendering (e.g. content was only whitespace) — prefer
// the original so callers never lose the underlying text.
return content
}
return out
}
+94
View File
@@ -0,0 +1,94 @@
package app
import (
"strings"
"testing"
)
// stripANSI removes CSI escape sequences so we can assert on the visible text
// glamour produced without depending on exact styling codes.
func stripANSI(s string) string {
var b strings.Builder
for i := 0; i < len(s); {
if s[i] == 0x1b && i+1 < len(s) && s[i+1] == '[' {
j := i + 2
for j < len(s) && (s[j] < 0x40 || s[j] > 0x7e) {
j++
}
if j < len(s) {
j++ // consume the final byte
}
i = j
continue
}
b.WriteByte(s[i])
i++
}
return b.String()
}
// Rich markdown should be transformed (output differs from raw input) while the
// literal words survive the round-trip.
func TestRenderMarkdownTransformsAndPreservesText(t *testing.T) {
in := "# Heading\n\nSome **bold** and *italic* text.\n\n- first item\n- second item\n\n`inline code` and:\n\n```go\nfmt.Println(\"hi\")\n```\n"
out := renderMarkdown(in, 80)
if out == in {
t.Fatalf("markdown input should be rendered, got identical output")
}
plain := stripANSI(out)
for _, want := range []string{"Heading", "bold", "italic", "first item", "second item", "inline code", "fmt.Println"} {
if !strings.Contains(plain, want) {
t.Errorf("rendered output missing %q\n--- visible ---\n%s", want, plain)
}
}
}
// A plain, short string must round-trip to contain its text and not panic,
// regardless of how much (or how little) styling glamour adds.
func TestRenderMarkdownPlainStringRoundTrips(t *testing.T) {
in := "just a short plain sentence"
out := renderMarkdown(in, 80)
if !strings.Contains(stripANSI(out), in) {
t.Fatalf("plain string did not survive rendering: %q", stripANSI(out))
}
}
// Pathological / empty inputs must never panic and must return something
// containing the input text (trivially true for empty input).
func TestRenderMarkdownPathologicalDoesNotPanic(t *testing.T) {
cases := []string{
"",
" ",
"\n\n\n",
"```unterminated code fence\nstill going",
strings.Repeat("a", 10_000),
"[](()]][[**__",
}
for _, in := range cases {
func() {
defer func() {
if r := recover(); r != nil {
t.Fatalf("renderMarkdown panicked on %q: %v", in, r)
}
}()
out := renderMarkdown(in, 40)
// Empty/whitespace-only inputs fall back to the original content.
if strings.TrimSpace(in) == "" {
if out != in {
t.Errorf("whitespace input %q should fall back to itself, got %q", in, out)
}
}
}()
}
}
// A non-positive width must not crash and should fall back to the default
// wrap width rather than feeding glamour an invalid value.
func TestRenderMarkdownZeroWidthFallsBack(t *testing.T) {
out := renderMarkdown("**hello** world", 0)
if !strings.Contains(stripANSI(out), "hello") {
t.Fatalf("zero-width render dropped text: %q", stripANSI(out))
}
}
@@ -0,0 +1,39 @@
package app
import "testing"
// scrollModal must clamp the body offset of a tall fixed-content modal (help / stats) to
// [0, scrollableModalMax] so paging past either end lands exactly — the modal scrolls
// instead of clipping its bottom (incl. the close hint) off-screen.
func TestModalScrollClampsToBounds(t *testing.T) {
m := inSessionModel(120, 24) // short enough that the help cheat-sheet overflows
m.overlay = OverlayHelp
body := m.activeModalBody()
max := m.scrollableModalMax(len(body))
if max <= 0 {
t.Fatalf("help body (%d lines) must exceed the modal viewport (%d) to test scrolling", len(body), m.modalBodyRows())
}
m.scrollModal(10_000) // past the bottom
if m.modalScroll != max {
t.Fatalf("scroll past end clamped to %d, want max %d", m.modalScroll, max)
}
m.scrollModal(-10_000) // past the top
if m.modalScroll != 0 {
t.Fatalf("scroll past top clamped to %d, want 0", m.modalScroll)
}
}
// A modal that fits the screen never scrolls.
func TestModalScrollNoOpWhenFits(t *testing.T) {
m := inSessionModel(120, 60) // tall enough that help fits in full
m.overlay = OverlayHelp
if max := m.scrollableModalMax(len(m.activeModalBody())); max != 0 {
t.Fatalf("help should fit at height 60, got max scroll %d", max)
}
m.scrollModal(5)
if m.modalScroll != 0 {
t.Fatalf("a modal that fits should not scroll, got %d", m.modalScroll)
}
}
+196 -5
View File
@@ -1,6 +1,9 @@
package app
import (
"strconv"
"strings"
"github.com/correx/tui-go/internal/protocol"
"github.com/correx/tui-go/internal/ws"
)
@@ -54,12 +57,20 @@ const (
OverlayStats
OverlayIdeas
OverlayHealth
OverlaySessions
OverlayTasks
OverlayFiles
OverlayStatusbar
OverlayGrants
OverlayGrantScope
OverlayHelp
)
// RouterEntry is one line in a session's conversation transcript.
type RouterEntry struct {
Role string // user | router | tool | narration | narration_llm
Role string // user | router | tool | narration | narration_llm | action
Content string
Icon string // action role only: the gutter glyph (✓ ✎ ✗ ⌘ ✕ ⊞ ⊟)
Metrics *TurnMetrics
}
@@ -111,6 +122,27 @@ type Approval struct {
Rationale []string
}
// tierNum parses the numeric part of a tier label ("T3" → 3). Unparseable or
// empty tiers return -1, which is treated as low-risk (never requires confirm).
func tierNum(tier string) int {
t := strings.TrimSpace(strings.ToUpper(tier))
t = strings.TrimPrefix(t, "T")
if t == "" {
return -1
}
n, err := strconv.Atoi(t)
if err != nil {
return -1
}
return n
}
// HighTier reports whether this gate is destructive/high-risk (T3+), so an approve
// must be confirmed with a second keypress rather than acting on one keystroke.
func (a *Approval) HighTier() bool {
return tierNum(a.Tier) >= 3
}
// ClarQuestion is one open question a stage raised (mirrors the wire DTO).
type ClarQuestion struct {
ID string
@@ -159,7 +191,12 @@ type Session struct {
Tools []ToolRecord
ToolsByStage map[string][]ManifestTool
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.
Pending *Approval
PendingQueue []*Approval // all pending gates, oldest first; navigated with ↑/↓
PendingIdx int // selected index into PendingQueue
Clar *Clarification // open questions awaiting answers (clarification view)
Propose *Proposal // router workflow suggestion awaiting a pick (propose view)
Active bool // an inference/tool is in flight (drives the spinner)
@@ -192,6 +229,11 @@ type Model struct {
wfVisible bool
wfPendingID string // workflow chosen, awaiting an intent line before StartSession
wfPendingName string
// launcher (idle screen): the active "what to launch" selection — 0 = chat, 1..N =
// workflows[i-1] — cycled with Tab and shown at the input's lower-right. railHidden
// folds away the idle status/quick-keys rail.
launcherWf int
railHidden bool
bgUpdates int
// input
@@ -199,9 +241,9 @@ type Model struct {
inputMode InputMode
inputBuffer string
inputCursor int
history map[string][]string
historyIndex int
savedBuffer string
inputHistory []string // submitted lines across all contexts (chat, intent, in-session), newest last
historyIndex int // -1 = editing the live buffer; else an index into inputHistory
savedBuffer string // live buffer stashed while walking history
// flow flags
sessionEntered bool
@@ -212,6 +254,11 @@ type Model struct {
routerMessages map[string][]RouterEntry
routerConnected bool
chatMode string
// transcriptSel is the index (into the selected session's transcript) of the message
// the operator has navigated to with ctrl+↑/↓; -1 = no selection (tail-follow). A
// selected message is highlighted, scrolled into view, and is what `y` copies.
transcriptSel int
copiedFlash int // frame at which a copy happened, for a brief "copied" footer note
// provider
currentModel string
@@ -231,7 +278,11 @@ type Model struct {
overlay OverlayKind
overlayEventIdx int
diffScrollOffset int
modalScroll int // body scroll for tall fixed-content modals (help, stats)
eventStripShown bool
// in-session right panel: 0 = events, 1 = changes (token usage + changed files),
// 2 = off (output full-width). Cycled with `d`.
rightPanel int
// artifact viewer (OverlayArtifacts) — populated by the artifact.list response
artifacts []protocol.ArtifactDto
@@ -264,20 +315,85 @@ type Model struct {
health *protocol.HealthDto
healthLoading bool
// session browser (OverlaySessions) — recent sessions fetched over HTTP from
// GET /sessions, so prior runs are reachable after a server restart + TUI reopen
// (the WS stream only carries live sessions, not the historical roster).
sessionList []SessionSummary
sessionListIndex int
sessionListLoading bool
sessionListErr string
// task board (OverlayTasks) — all tasks across projects, fetched over HTTP from
// GET /tasks. taskDetail toggles the per-task detail pane (built from the list payload).
taskList []TaskSummary
taskListIndex int
taskListLoading bool
taskListErr string
taskDetail bool
taskDetailScroll int
taskFilter string // `/` substring narrow over id/title/goal
taskFilterTyping bool
// command palette
paletteFilter string
paletteIndex int
// @ file picker (OverlayFiles) — workspace paths from the file.list reply
files []string // all workspace-relative paths for filesFor
filesFor string // sessionId the file list belongs to
filesLoading bool
fileFilter string // the query typed after @ (narrows the list)
fileIndex int
// status-bar segment visibility (OverlayStatusbar) — persisted TUI-local in tui-prefs.json.
// A segment id present in sbHidden is hidden; absent = shown. sbIndex is the toggle cursor.
sbHidden map[string]bool
sbIndex int
// actionsHidden mutes the inline action rows (tool calls / writes / approvals / grants) in
// the OUTPUT transcript; they always stay in the EVENTS panel. Persisted in tui-prefs.json.
actionsHidden bool
// outputScroll is how many rows up from the bottom the OUTPUT transcript is scrolled
// (0 = tail-follow the newest output). PgUp/PgDn + ctrl+u/d move it; esc snaps back.
outputScroll int
// event-inspector filter (OverlayEventInspector): narrows the event list by a substring
// of type/detail. eventFilterTyping is true while the operator is editing the query after /.
eventFilter string
eventFilterTyping bool
// standing grants (OverlayGrants) — active PROJECT/GLOBAL grants from the grant.list reply.
grants []protocol.GrantDto
grantsLoading bool
grantIndex int
// grant scope picker (OverlayGrantScope) — chosen when the operator presses A on an approval.
// grantScopeIndex selects session/project/global; grantFor holds the approval being widened.
grantScopeIndex int
grantFor *Approval
// animation
frame int // tick counter; drives spinner + caret blink
ticking bool // true while a tick loop is scheduled. The loop is gated on animating()
// so an idle screen stops redrawing — which lets native terminal selection survive
// (the 120ms redraw used to wipe a mouse drag every frame).
// snapshot phase
snapshotPhase bool
pendingEvents []protocol.ServerMessage
// lastBase is the full-screen render behind the active modal, stashed by View each
// frame so center() can composite a modal over a dimmed copy of it (transparent
// backdrop) instead of an opaque scrim. Transient render scratch — not real state.
lastBase string
// approval steering input buffer
steerBuffer string
steering bool
// approvalArmed is set while a high-tier (T3+) approve is awaiting its second
// confirming keypress — the destructive-action safety. Any non-confirm key
// disarms it; a confirming `y`/`a`/enter sends the decision.
approvalArmed bool
// clarification view (the interactive question form)
clarFocus int // focused question index
@@ -301,7 +417,6 @@ func NewModel(client *ws.Client) Model {
theme: NewTheme(SoftBlue),
wfIndex: -1,
inputMode: ModeRouter,
history: map[string][]string{},
historyIndex: -1,
routerMessages: map[string][]RouterEntry{},
configStaged: map[string]string{},
@@ -309,6 +424,9 @@ func NewModel(client *ws.Client) Model {
providerType: "LOCAL",
snapshotPhase: true,
eventStripShown: true,
transcriptSel: -1,
sbHidden: loadStatusbarHidden(),
actionsHidden: loadPrefs().InlineActionsHidden,
}
}
@@ -348,6 +466,79 @@ func (m Model) session(id string) *Session {
return nil
}
// syncPending re-points Pending at the selected queue entry, clamping the index
// so it stays in range as the queue grows and shrinks. Called after any queue
// mutation; Pending is nil exactly when the queue is empty.
func (s *Session) syncPending() {
if len(s.PendingQueue) == 0 {
s.PendingQueue = nil
s.PendingIdx = 0
s.Pending = nil
return
}
if s.PendingIdx < 0 {
s.PendingIdx = 0
}
if s.PendingIdx >= len(s.PendingQueue) {
s.PendingIdx = len(s.PendingQueue) - 1
}
s.Pending = s.PendingQueue[s.PendingIdx]
}
// enqueueApproval adds a gate to the queue (replacing one with the same request
// id, so a re-sent gate doesn't duplicate). The freshly-added gate is left where
// it is in the order; the current selection is preserved.
func (s *Session) enqueueApproval(a *Approval) {
for i, p := range s.PendingQueue {
if p.RequestID == a.RequestID {
s.PendingQueue[i] = a
s.syncPending()
return
}
}
s.PendingQueue = append(s.PendingQueue, a)
s.syncPending()
}
// removeApproval drops the gate with requestID and advances the selection. If the
// removed entry was before the cursor the index shifts down to stay on the same
// gate; if it was the selected one the cursor stays put (now showing the next gate).
func (s *Session) removeApproval(requestID string) {
idx := -1
for i, p := range s.PendingQueue {
if p.RequestID == requestID {
idx = i
break
}
}
if idx < 0 {
return
}
s.PendingQueue = append(s.PendingQueue[:idx], s.PendingQueue[idx+1:]...)
if idx < s.PendingIdx {
s.PendingIdx--
}
s.syncPending()
}
// clearApprovals empties the queue (used on resume/completion, where the server
// invalidates every gate at once).
func (s *Session) clearApprovals() {
s.PendingQueue = nil
s.PendingIdx = 0
s.Pending = nil
}
// navApproval moves the queue selection by dir (wrapping) and re-syncs Pending.
func (s *Session) navApproval(dir int) {
n := len(s.PendingQueue)
if n <= 1 {
return
}
s.PendingIdx = (s.PendingIdx + dir + n) % n
s.syncPending()
}
// ensureSession returns the session with id, creating an ACTIVE entry if absent.
// Session existence is derived from the event stream (any event for an unknown
// session vivifies it) rather than a dedicated control frame.
@@ -0,0 +1,41 @@
package app
import "testing"
// scrollOutput must clamp to [0, totalRows-panelHeight] against the same geometry the renderer
// windows to — so PgUp past the top and PgDn past the bottom land exactly on the edges (no
// dead presses unwinding an overshoot).
func TestOutputScrollClampsToBounds(t *testing.T) {
m := inSessionModel(120, 30)
msgs := make([]RouterEntry, 0, 60)
for i := 0; i < 60; i++ {
msgs = append(msgs, RouterEntry{Role: "router", Content: "a transcript line"})
}
m.routerMessages[m.selectedID] = msgs
w, h := m.outputViewport()
rows, _ := m.buildTranscriptRows(w)
max := len(rows) - h
if max <= 0 {
t.Fatalf("fixture transcript (%d rows) must exceed panel height (%d) to test scrolling", len(rows), h)
}
m.scrollOutput(10_000) // way past the top
if m.outputScroll != max {
t.Fatalf("scroll up clamped to %d, want max %d", m.outputScroll, max)
}
m.scrollOutput(-10_000) // way past the bottom
if m.outputScroll != 0 {
t.Fatalf("scroll down clamped to %d, want 0 (tail-follow)", m.outputScroll)
}
}
// A transcript that fits the panel never scrolls (stays tail-following).
func TestOutputScrollNoOpWhenFits(t *testing.T) {
m := inSessionModel(120, 30)
m.routerMessages[m.selectedID] = []RouterEntry{{Role: "router", Content: "one line"}}
m.scrollOutput(5)
if m.outputScroll != 0 {
t.Fatalf("short transcript should not scroll, got %d", m.outputScroll)
}
}
@@ -0,0 +1,52 @@
package app
import (
"strings"
"testing"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
)
func fullScreen(ch string, w, h int) string {
row := strings.Repeat(ch, w)
rows := make([]string, h)
for i := range rows {
rows[i] = row
}
return strings.Join(rows, "\n")
}
// A composited modal must still fill the whole screen exactly (h lines, each w cols),
// so the alt-screen never shows a torn/short region around a "transparent" modal.
func TestCompositeOverlayGeometry(t *testing.T) {
m := NewModel(nil)
m.width, m.height = 40, 12
m.lastBase = fullScreen("x", 40, 12)
modal := lipgloss.NewStyle().Width(10).Border(lipgloss.RoundedBorder()).Render("hi\nthere")
out := m.center(modal)
lines := strings.Split(out, "\n")
if len(lines) != 12 {
t.Fatalf("height = %d, want 12", len(lines))
}
for i, ln := range lines {
if w := ansi.StringWidth(ln); w != 40 {
t.Fatalf("line %d visible width = %d, want 40", i, w)
}
}
}
// The idempotence guard: a modal that is already full-screen (a builder that centered
// internally, then got re-centered) must pass through unchanged — not get re-dimmed.
func TestCenterIdempotentOnFullScreen(t *testing.T) {
m := NewModel(nil)
m.width, m.height = 30, 8
m.lastBase = fullScreen("y", 30, 8)
full := fullScreen("M", 30, 8)
if got := m.center(full); got != full {
t.Fatal("center should pass a full-screen modal through unchanged")
}
}
+496 -33
View File
@@ -2,20 +2,82 @@ package app
import (
"fmt"
"image/color"
"sort"
"strings"
"github.com/charmbracelet/lipgloss"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
)
// center composites a modal over a scrim-filled screen. Immediate-mode: the modal
// is recomputed from state every frame, so there is no separate show/restore path
// to desync (the "diff won't come back" bug class can't occur here).
// center draws a modal centered over a *dimmed* copy of the screen behind it, so the
// transcript stays faintly visible around the modal (a transparent backdrop) rather
// than being hidden by an opaque scrim. Immediate-mode: recomputed from state every
// frame, so there is no show/restore path to desync.
//
// Idempotence guard: some modal builders call center() internally and then get centered
// again by renderOverlay. The inner call already produced a full-screen composite, so a
// second call (input already width×height) returns it unchanged instead of dimming the
// modal itself. Falls back to an opaque scrim when no base was stashed (defensive).
func (m Model) center(modal string) string {
if m.lastBase == "" {
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, modal,
lipgloss.WithWhitespaceBackground(m.theme.P.BgDeep),
lipgloss.WithWhitespaceForeground(m.theme.P.BgDeep))
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().
Background(m.theme.P.BgDeep).Foreground(m.theme.P.BgDeep)))
}
if lipgloss.Height(modal) >= m.height && lipgloss.Width(modal) >= m.width {
return modal // already a full-screen composite; don't re-dim it
}
return m.compositeOverlay(m.lastBase, modal)
}
// compositeOverlay strips + dims base into a faint scrim, then splices the modal box
// over it centered. ANSI-aware so the side margins keep the (dimmed) content behind.
// base and the result are both m.width × m.height.
func (m Model) compositeOverlay(base, modal string) string {
w, h := m.width, m.height
dim := lipgloss.NewStyle().Foreground(m.theme.P.Faint).Background(m.theme.P.BgDeep)
baseLines := strings.Split(base, "\n")
scrim := make([]string, h)
for i := 0; i < h; i++ {
raw := ""
if i < len(baseLines) {
raw = ansi.Strip(baseLines[i])
}
scrim[i] = dim.Render(padRightRaw(raw, w))
}
modalLines := strings.Split(modal, "\n")
mw := lipgloss.Width(modal)
top := (h - len(modalLines)) / 2
left := (w - mw) / 2
if top < 0 {
top = 0
}
if left < 0 {
left = 0
}
for r, ml := range modalLines {
row := top + r
if row < 0 || row >= h {
continue
}
leftPart := ansi.Truncate(scrim[row], left, "")
rightPart := ansi.TruncateLeft(scrim[row], left+mw, "")
scrim[row] = leftPart + ml + rightPart
}
return strings.Join(scrim, "\n")
}
// padRightRaw pads an unstyled string with spaces to exactly w columns (or truncates).
func padRightRaw(s string, w int) string {
switch sw := ansi.StringWidth(s); {
case sw > w:
return ansi.Truncate(s, w, "")
case sw < w:
return s + strings.Repeat(" ", w-sw)
default:
return s
}
}
func (m Model) modalWidth() int {
@@ -63,6 +125,20 @@ func (m Model) renderOverlay(base string) string {
return m.center(m.ideasModal())
case OverlayHealth:
return m.center(m.healthModal())
case OverlaySessions:
return m.center(m.sessionsModal())
case OverlayTasks:
return m.center(m.tasksModal())
case OverlayFiles:
return m.center(m.filesModal())
case OverlayStatusbar:
return m.center(m.statusbarModal())
case OverlayGrants:
return m.center(m.grantsModal())
case OverlayGrantScope:
return m.center(m.grantScopeModal())
case OverlayHelp:
return m.center(m.helpModal())
}
return base
}
@@ -72,7 +148,7 @@ func (m Model) titleLine(text string) string {
return lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Bold(true).Render(text)
}
func mbg(t Theme, s string, fg lipgloss.Color) string {
func mbg(t Theme, s string, fg color.Color) string {
return lipgloss.NewStyle().Foreground(fg).Background(t.P.BgPanel).Render(s)
}
@@ -132,6 +208,13 @@ func (m Model) renderApprovalBand(h int) string {
rightHdr := t.span("tier ", t.P.Faint) + t.span(a.Tier, t.P.Accent2) +
t.span(" · risk ", t.P.Faint) +
lipgloss.NewStyle().Foreground(riskColor).Background(t.P.Bg).Bold(true).Render(strings.ToUpper(a.Risk))
// When more than one gate is queued, show "approval i/n" so the operator knows
// there are others behind this one (↑/↓ to walk them) — never stack modals.
if n := len(s.PendingQueue); n > 1 {
rightHdr = t.span("approval ", t.P.Faint) +
t.span(itoa(s.PendingIdx+1)+"/"+itoa(n), t.P.Accent) +
t.span(" · ", t.P.Faint) + rightHdr
}
innerH := h - 2
ratBlock := 0
@@ -218,8 +301,19 @@ func (m Model) approvalActions() string {
hint("enter", "approve + send note"), hint("esc", "keep note, decide below"),
}, gap)
}
parts := []string{hint("a", "approve"), hint("A", "auto"), hint("s", "steer"), hint("r", "reject")}
if s := m.session(m.selectedID); s != nil && s.Pending != nil &&
s := m.session(m.selectedID)
// Armed T3+ approve: a single decisive line so an accidental keystroke can't slip
// a destructive action through — the confirm key must be pressed again.
if m.approvalArmed && s != nil && s.Pending != nil {
warn := lipgloss.NewStyle().Foreground(t.P.Bad).Background(t.P.Bg).Bold(true).
Render("press y again to confirm " + s.Pending.Tier + " approval")
return warn + gap + hint("any other key", "cancel")
}
parts := []string{hint("y", "approve"), hint("n", "reject"), hint("e", "steer"), hint("A", "auto")}
if s != nil && len(s.PendingQueue) > 1 {
parts = append(parts, hint("↑↓", "queue"))
}
if s != nil && s.Pending != nil &&
(isUnifiedDiff(s.Pending.Preview) || isCommandApproval(s.Pending)) {
parts = append(parts, hint("^x", "fullscreen"))
}
@@ -264,6 +358,20 @@ func (m Model) diffMaxScroll() int {
return max
}
// scrollDiff moves the diff/preview/command modal offset by delta rows, clamped to
// [0, diffMaxScroll] so paging past either end lands exactly on the edge (no dead
// presses unwinding an overshoot — same contract as the OUTPUT free-scroll).
func (m *Model) scrollDiff(delta int) {
off := m.diffScrollOffset + delta
if mx := m.diffMaxScroll(); off > mx {
off = mx
}
if off < 0 {
off = 0
}
m.diffScrollOffset = off
}
func (m Model) diffModal() string {
if cmd := m.pendingCommand(); cmd != nil {
return m.commandModal(cmd)
@@ -301,7 +409,7 @@ func (m Model) diffModal() string {
for _, ln := range lines {
b.WriteString(ln + "\n")
}
b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "scroll"}, {"^x/esc", "close"}}))
b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "line"}, {"PgUp/Dn", "page"}, {"g/G", "ends"}, {"^x/esc", "close"}}))
modal := t.Overlay.Width(w).Render(b.String())
return m.center(modal)
@@ -335,7 +443,7 @@ func (m Model) commandModal(a *Approval) string {
for i := off; i < end; i++ {
b.WriteString(lines[i] + "\n")
}
b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "scroll"}, {"^x/esc", "close"}}))
b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "line"}, {"PgUp/Dn", "page"}, {"g/G", "ends"}, {"^x/esc", "close"}}))
return m.center(t.Overlay.Width(w).Render(b.String()))
}
@@ -350,8 +458,25 @@ func (m Model) eventInspectorModal() string {
b.WriteString(mbg(t, " ("+itoa(m.overlayEventIdx+1)+"/"+itoa(len(evs))+")", t.P.Faint))
}
b.WriteString("\n\n")
// Filter line (shown while editing or when a query is set): a `/` prompt over the query.
if m.eventFilterTyping || m.eventFilter != "" {
caret := mbg(t, "", t.P.BgPanel)
if m.eventFilterTyping && m.caretVisible() {
caret = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▏")
}
prompt := lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("/ ")
if m.eventFilter == "" {
b.WriteString(prompt + caret + mbg(t, "type to filter events…", t.P.Faint) + "\n\n")
} else {
b.WriteString(prompt + mbg(t, m.eventFilter, t.P.FgStrong) + caret + "\n\n")
}
}
if len(evs) == 0 {
b.WriteString(mbg(t, "no events", t.P.Faint))
msg := "no events"
if m.eventFilter != "" {
msg = "no events match \"" + m.eventFilter + "\""
}
b.WriteString(mbg(t, msg, t.P.Faint))
return m.center(t.Overlay.Width(w).Render(b.String()))
}
// Budget the plain detail so the styled row never needs ANSI-aware truncation
@@ -395,7 +520,7 @@ func (m Model) eventInspectorModal() string {
mbg(t, " "+truncate(e.Detail, detailBudget), t.P.Dim)
b.WriteString(row + "\n")
}
b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "select"}, {"esc", "close"}}))
b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "select"}, {"/", "filter"}, {"c", "clear"}, {"esc", "close"}}))
modal := t.Overlay.Width(w).Render(b.String())
return m.center(modal)
@@ -432,8 +557,13 @@ func (m Model) paletteModal() string {
marker = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▌ ")
titleFg = t.P.FgStrong
}
b.WriteString(marker +
lipgloss.NewStyle().Foreground(titleFg).Background(t.P.BgPanel).Render(padRaw(c.title, 18)) +
// keybind column: the bare-key shortcut, so the palette teaches the shortcuts.
keyCell := mbg(t, padRaw("", 4), t.P.BgPanel)
if c.key != "" {
keyCell = lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.BgPanel).Bold(true).Render(padRaw(c.key, 4))
}
b.WriteString(marker + keyCell +
lipgloss.NewStyle().Foreground(titleFg).Background(t.P.BgPanel).Render(padRaw(c.title, 16)) +
mbg(t, " "+c.hint, t.P.Faint) + "\n")
}
b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "select"}, {"enter", "run"}, {"esc", "close"}}))
@@ -441,6 +571,312 @@ func (m Model) paletteModal() string {
return m.center(modal)
}
// filesModal is the `@` file-reference picker: a filter line over the session workspace's
// paths, windowed around the selection; enter inserts `@path` into the chat input.
func (m Model) filesModal() string {
t := m.theme
w := m.modalWidth()
files := m.filteredFiles()
var b strings.Builder
b.WriteString(m.titleLine("@ file reference") + "\n\n")
caret := mbg(t, " ", t.P.BgPanel)
if m.caretVisible() {
caret = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▏")
}
prompt := lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("@ ")
if m.fileFilter == "" {
b.WriteString(prompt + caret + mbg(t, "type to filter files…", t.P.Faint) + "\n\n")
} else {
b.WriteString(prompt + mbg(t, m.fileFilter, t.P.FgStrong) + caret + "\n\n")
}
switch {
case m.filesLoading:
b.WriteString(mbg(t, " loading…", t.P.Faint) + "\n")
case len(m.files) == 0:
b.WriteString(mbg(t, " no workspace files (is a workspace bound?)", t.P.Faint) + "\n")
case len(files) == 0:
b.WriteString(mbg(t, " no matching files", t.P.Faint) + "\n")
default:
const maxRows = 12
start := 0
if m.fileIndex >= maxRows {
start = m.fileIndex - maxRows + 1
}
end := start + maxRows
if end > len(files) {
end = len(files)
}
for i := start; i < end; i++ {
marker := mbg(t, " ", t.P.BgPanel)
fg := t.P.Fg
if i == m.fileIndex {
marker = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▌ ")
fg = t.P.FgStrong
}
b.WriteString(marker +
lipgloss.NewStyle().Foreground(fg).Background(t.P.BgPanel).Render(clip(files[i], w-6)) + "\n")
}
if len(files) > maxRows {
b.WriteString(mbg(t, " "+itoa(len(files))+" matches", t.P.Faint) + "\n")
}
}
b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "select"}, {"enter", "insert"}, {"esc", "close"}}))
modal := t.Overlay.Width(w).Render(b.String())
return m.center(modal)
}
// statusbarModal toggles which status-bar segments are shown. Choices persist to the local
// tui-prefs.json so the bar stays as configured across launches.
func (m Model) statusbarModal() string {
t := m.theme
w := m.modalWidth()
var b strings.Builder
b.WriteString(m.titleLine("status bar") + "\n\n")
b.WriteString(mbg(t, " show / hide segments (correx, connection, session name stay)", t.P.Faint) + "\n\n")
for i, seg := range statusSegments {
marker := mbg(t, " ", t.P.BgPanel)
labelFg := t.P.Fg
if i == m.sbIndex {
marker = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▌ ")
labelFg = t.P.FgStrong
}
box := lipgloss.NewStyle().Foreground(t.P.OK).Background(t.P.BgPanel).Render("[✓] ")
if !m.sbShow(seg.id) {
box = mbg(t, "[ ] ", t.P.Faint)
}
b.WriteString(marker + box +
lipgloss.NewStyle().Foreground(labelFg).Background(t.P.BgPanel).Render(seg.label) + "\n")
}
b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "move"}, {"space", "toggle"}, {"esc", "close"}}))
modal := t.Overlay.Width(w).Render(b.String())
return m.center(modal)
}
// grantsModal lists the active standing (PROJECT/GLOBAL) grants and lets the operator revoke one.
// These are cross-session auto-approvals, so the board opens from anywhere.
func (m Model) grantsModal() string {
t := m.theme
w := m.modalWidth()
var b strings.Builder
b.WriteString(m.titleLine("standing grants") + "\n\n")
b.WriteString(mbg(t, " cross-session auto-approvals in force (tool-bound)", t.P.Faint) + "\n\n")
switch {
case m.grantsLoading:
b.WriteString(mbg(t, " loading…", t.P.Faint) + "\n")
case len(m.grants) == 0:
b.WriteString(mbg(t, " none — press A on an approval to grant project / global", t.P.Faint) + "\n")
default:
for i, g := range m.grants {
marker := mbg(t, " ", t.P.BgPanel)
fg := t.P.Fg
if i == m.grantIndex {
marker = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▌ ")
fg = t.P.FgStrong
}
scope := lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.BgPanel).Render(padRaw(g.Scope, 8))
tool := lipgloss.NewStyle().Foreground(fg).Background(t.P.BgPanel).Render(padRaw(g.ToolName, 16))
tiers := mbg(t, "≤"+strings.Join(g.Tiers, ","), t.P.Faint)
b.WriteString(marker + scope + tool + tiers)
if g.Scope == "PROJECT" && g.ProjectID != "" {
b.WriteString(mbg(t, " "+clip(g.ProjectID, w-52), t.P.Faint))
}
b.WriteString("\n")
}
}
b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "select"}, {"x", "revoke"}, {"G/esc", "close"}}))
modal := t.Overlay.Width(w).Render(b.String())
return m.center(modal)
}
// grantScopeModal is the A (approve-always) breadth picker: choose how widely to stop being asked
// for the pending tool — this session, this project, or everywhere.
func (m Model) grantScopeModal() string {
t := m.theme
w := m.modalWidth()
tool := ""
if m.grantFor != nil {
tool = m.grantFor.ToolName
}
var b strings.Builder
b.WriteString(m.titleLine("approve always — choose scope") + "\n\n")
b.WriteString(mbg(t, " stop asking for ", t.P.Faint) +
lipgloss.NewStyle().Foreground(t.P.FgStrong).Background(t.P.BgPanel).Render(tool) + "\n\n")
for i, opt := range grantScopeOptions() {
marker := mbg(t, " ", t.P.BgPanel)
labelFg := t.P.Fg
if i == m.grantScopeIndex {
marker = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▌ ")
labelFg = t.P.FgStrong
}
key := lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.BgPanel).Render(padRaw(opt.key, 3))
title := lipgloss.NewStyle().Foreground(labelFg).Background(t.P.BgPanel).Render(padRaw(opt.title, 16))
b.WriteString(marker + key + title + mbg(t, opt.hint, t.P.Faint) + "\n")
}
b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "move"}, {"enter", "grant"}, {"esc", "cancel"}}))
modal := t.Overlay.Width(w).Render(b.String())
return m.center(modal)
}
// helpModal is the keybinding cheat-sheet (?), grouped by context. Any key dismisses it.
// modalBodyRows is how many body lines a scrollable fixed-content modal shows: the
// screen height minus the modal chrome (border 2 + padding 2 + title block 2 + hint
// block 2). Sized so a modal that fits shows in full and a taller one scrolls rather
// than clipping its bottom off-screen (the old compositeOverlay behaviour).
func (m Model) modalBodyRows() int {
if r := m.height - 8; r > 3 {
return r
}
return 3
}
// scrollableModalMax is the largest scroll offset for a body of n lines, anchoring the
// last page to the bottom.
func (m Model) scrollableModalMax(n int) int {
if max := n - m.modalBodyRows(); max > 0 {
return max
}
return 0
}
// activeModalBody returns the body lines of whichever scrollable fixed-content modal is
// open, so the key handler can clamp m.modalScroll against the same content it renders.
func (m Model) activeModalBody() []string {
switch m.overlay {
case OverlayHelp:
return m.helpBody()
case OverlayStats:
if m.statsLoading || m.stats == nil || m.statsFor != m.selectedID {
return nil
}
return m.statsBody()
}
return nil
}
// scrollModal moves the active fixed-content modal's body offset by delta lines, clamped
// to [0, scrollableModalMax] — same contract as the diff/output scrollers.
func (m *Model) scrollModal(delta int) {
max := m.scrollableModalMax(len(m.activeModalBody()))
off := m.modalScroll + delta
if off > max {
off = max
}
if off < 0 {
off = 0
}
m.modalScroll = off
}
// renderScrollModal lays out a title + scrollable body + pinned hint inside the modal box,
// windowing body by m.modalScroll so the box never exceeds the screen. A position indicator
// appears in the title row whenever the body is taller than the viewport.
func (m Model) renderScrollModal(title, titleSuffix string, body []string, hint [][2]string) string {
t := m.theme
w := m.modalWidth()
rows := m.modalBodyRows()
off := m.modalScroll
if mx := m.scrollableModalMax(len(body)); off > mx {
off = mx
}
if off < 0 {
off = 0
}
end := off + rows
if end > len(body) {
end = len(body)
}
var b strings.Builder
b.WriteString(m.titleLine(title))
if titleSuffix != "" {
b.WriteString(mbg(t, titleSuffix, t.P.Faint))
}
if len(body) > rows {
b.WriteString(mbg(t, " ("+itoa(off+1)+"-"+itoa(end)+"/"+itoa(len(body))+")", t.P.Faint))
}
b.WriteString("\n\n")
for i := off; i < end; i++ {
b.WriteString(body[i] + "\n")
}
b.WriteString("\n" + modalHints(t, hint))
return t.Overlay.Width(w).Render(b.String())
}
// helpBody is the keybinding cheat-sheet as discrete lines (so renderScrollModal can window it).
func (m Model) helpBody() []string {
t := m.theme
type kb struct{ key, desc string }
var out []string
section := func(title string, rows []kb) {
out = append(out, lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.BgPanel).Bold(true).Render(title))
for _, r := range rows {
key := lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render(padRaw(r.key, 16))
out = append(out, " "+key+mbg(t, r.desc, t.P.Fg))
}
out = append(out, "")
}
section("navigate", []kb{
{"↑↓ / j k", "move the session / list selection"},
{"enter", "open the selected session"},
{"Tab / w", "idle: cycle launch target (chat / workflow)"},
{"/", "filter the session list"},
{"^↑ / ^↓", "jump to your previous / next message"},
{"PgUp / PgDn", "scroll output (^u / ^d half-page)"},
{"esc", "drop selection / scroll → follow newest"},
{"l", "back to the session list"},
})
section("session", []kb{
{"i", "compose a message"},
{"d", "right panel: events / changes / off"},
{"s", "toggle chat / steering mode"},
{"w", "show workflows (idle screen)"},
{"y", "copy the selected message (OSC52)"},
{"^x", "open the latest diff / preview"},
{"c", "cancel the running session"},
{"a", "re-open a dismissed approval gate"},
{"q", "quit"},
})
section("overlays", []kb{
{"p", "command palette (all shortcuts)"},
{"?", "this keybindings help"},
{"e", "event inspector (/ to filter)"},
{"t / v", "tools / artifacts"},
{"S / R", "session stats / resume past sessions"},
{"T", "task board (across projects)"},
{"m / g", "swap model / edit config"},
{"G / I", "grants / idea board"},
})
section("approval band", []kb{
{"y / a / enter", "approve once (T3+ asks again to confirm)"},
{"n / r", "reject"},
{"e / s", "steer (add a note)"},
{"A", "approve-always — choose scope"},
{"↑ / ↓", "walk the pending queue"},
{"^x", "fullscreen the diff / command preview"},
{"esc", "dismiss for now (a re-opens)"},
})
section("diff / preview viewer (^x)", []kb{
{"↑ / ↓", "scroll a line"},
{"PgUp / PgDn", "scroll a page (^u / ^d half)"},
{"g / G", "jump to top / end"},
{"^x / esc", "close"},
})
return out
}
func (m Model) helpModal() string {
return m.renderScrollModal("keybindings", "", m.helpBody(),
[][2]string{{"↑↓", "scroll"}, {"esc", "close"}})
}
func (m Model) toolPaletteModal() string {
t := m.theme
w := m.modalWidth()
@@ -550,6 +986,20 @@ func (m Model) artifactContentMaxScroll(w int) int {
return max
}
// scrollArtifact moves the selected artifact's content offset by delta lines, clamped to
// the scrollable range (same contract as the diff/output scrollers).
func (m *Model) scrollArtifact(delta int) {
max := m.artifactContentMaxScroll(m.modalWidth() - 6)
off := m.artifactScroll + delta
if off > max {
off = max
}
if off < 0 {
off = 0
}
m.artifactScroll = off
}
func (m Model) artifactsModal() string {
t := m.theme
w := m.modalWidth()
@@ -628,7 +1078,7 @@ func (m Model) artifactsModal() string {
b.WriteString(mbg(t, " "+lines[i], t.P.Fg) + "\n")
}
b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "select"}, {"PgUp/Dn", "scroll"}, {"v/esc", "close"}}))
b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "select"}, {"PgUp/Dn", "page"}, {"g/G", "ends"}, {"v/esc", "close"}}))
return t.Overlay.Width(w).Render(b.String())
}
@@ -660,14 +1110,14 @@ func (m Model) statsModal() string {
t := m.theme
w := m.modalWidth()
// Loading / empty: a small fixed modal, no scroll needed.
if m.statsLoading || m.stats == nil || m.statsFor != m.selectedID {
var b strings.Builder
b.WriteString(m.titleLine("session stats"))
if m.selectedID != "" {
b.WriteString(mbg(t, " ("+shortID(m.selectedID)+")", t.P.Faint))
}
b.WriteString("\n\n")
if m.statsLoading || m.stats == nil || m.statsFor != m.selectedID {
msg := "loading…"
if !m.statsLoading && m.stats == nil {
msg = "no stats for this session"
@@ -677,15 +1127,29 @@ func (m Model) statsModal() string {
return t.Overlay.Width(w).Render(b.String())
}
suffix := ""
if m.selectedID != "" {
suffix = " (" + shortID(m.selectedID) + ")"
}
return m.renderScrollModal("session stats", suffix, m.statsBody(),
[][2]string{{"↑↓", "scroll"}, {"S/esc", "close"}})
}
// statsBody is the session-stats report as discrete lines (so renderScrollModal can window it).
// Callers must ensure m.stats is loaded for the selected session.
func (m Model) statsBody() []string {
t := m.theme
s := m.stats
cw := w - 4 // modal inner content width (borders + padding)
cw := m.modalWidth() - 4 // modal inner content width (borders + padding)
var out []string
section := func(label string) string {
return lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.BgPanel).Bold(true).Render(label)
}
// put clips each data line to the inner width so it never wraps on a slim modal.
put := func(styled string) { b.WriteString(clip(styled, cw) + "\n") }
put := func(styled string) { out = append(out, clip(styled, cw)) }
line := func(s string) string { return mbg(t, s, t.P.Fg) }
faint := func(s string) string { return mbg(t, s, t.P.Faint) }
blank := func() { out = append(out, "") }
// nameW shrinks the breakdown name column on slim terminals.
nameW := 20
if cw < 52 {
@@ -695,10 +1159,10 @@ func (m Model) statsModal() string {
// header row: duration + event count
put(faint(" duration ") + line(humanDurMs(s.SessionDurationMs)) +
faint(" events ") + line(itoa(int(s.EventCount))))
b.WriteString("\n")
blank()
// inference
b.WriteString(section("Inference") + "\n")
out = append(out, section("Inference"))
put(line(fmt.Sprintf(" %d calls · %d+%d tok · %.1f tok/s",
s.InferenceCount, s.PromptTokens, s.CompletionTokens, s.TokensPerSecond)))
for i, p := range s.PerProvider {
@@ -709,10 +1173,10 @@ func (m Model) statsModal() string {
put(faint(fmt.Sprintf(" %-*s %d× %d tok %.1f t/s",
nameW, padRaw(p.Provider, nameW), p.CompletedCount, p.PromptTokens+p.CompletionTokens, p.TokensPerSecond)))
}
b.WriteString("\n")
blank()
// tools
b.WriteString(section("Tools") + "\n")
out = append(out, section("Tools"))
put(line(fmt.Sprintf(" %d calls · %d ms", s.ToolCount, s.ToolMs)))
for i, tl := range s.PerTool {
if i >= statsBreakdownRows {
@@ -721,35 +1185,34 @@ func (m Model) statsModal() string {
}
put(faint(fmt.Sprintf(" %-*s %d× %d ms", nameW, padRaw(tl.ToolName, nameW), tl.CompletedCount, tl.TotalDurationMs)))
}
b.WriteString("\n")
blank()
// approvals
b.WriteString(section("Approvals") + "\n")
out = append(out, section("Approvals"))
put(line(fmt.Sprintf(" %d req · %d res · %d pending · avg %d ms",
s.ApprovalsRequested, s.ApprovalsResolved, s.ApprovalsPending, s.AvgApprovalWaitMs)))
for _, tr := range s.PerTier {
put(faint(fmt.Sprintf(" %-3s req %d res %d avg %d ms", tr.Tier, tr.RequestedCount, tr.ResolvedCount, tr.AvgWaitMs)))
}
b.WriteString("\n")
blank()
// failures
f := s.Failures
b.WriteString(section("Failures") + "\n")
out = append(out, section("Failures"))
put(line(fmt.Sprintf(" inf %d · t/o %d · tool %d · rej %d · stg %d · wf %d",
f.InferenceFailures, f.InferenceTimeouts, f.ToolFailures, f.ToolRejections, f.StageFailures, f.WorkflowFailures)))
b.WriteString("\n")
blank()
// time accounting
other := 100.0 - s.InferencePct - s.ToolPct - s.ApprovalWaitPct
if other < 0 {
other = 0
}
b.WriteString(section("Time") + "\n")
out = append(out, section("Time"))
put(line(fmt.Sprintf(" inf %.1f%% · tools %.1f%% · wait %.1f%% · other %.1f%%",
s.InferencePct, s.ToolPct, s.ApprovalWaitPct, other)))
b.WriteString("\n" + modalHints(t, [][2]string{{"S/esc", "close"}}))
return t.Overlay.Width(w).Render(b.String())
return out
}
func (m Model) healthModal() string {
@@ -820,7 +1283,7 @@ func shortID(id string) string {
return id[:13] + "…"
}
func tierColor(t Theme, tier int) lipgloss.Color {
func tierColor(t Theme, tier int) color.Color {
switch {
case tier >= 3:
return t.P.Bad
+99
View File
@@ -0,0 +1,99 @@
package app
import (
"encoding/json"
"os"
"path/filepath"
)
// prefs is the TUI's local, persisted preferences (display-only client state — kept out
// of the server config, which is shared/replayed). Stored as JSON in the correx config dir.
type prefs struct {
StatusbarHidden []string `json:"statusbarHidden"`
InlineActionsHidden bool `json:"inlineActionsHidden"`
}
// statusSegment is one toggleable status-bar segment. The always-on segments (correx label,
// connection, session name, spinner, background-updates) are not listed — they are load-bearing.
type statusSegment struct{ id, label string }
var statusSegments = []statusSegment{
{"stage", "current stage (⟐)"},
{"status", "session status"},
{"workspace", "workspace path (⌂)"},
{"model", "model name"},
{"clock", "last-event clock"},
{"gauge", "resource gauge"},
}
func prefsPath() string {
dir := os.Getenv("CORREX_CONFIG_HOME")
if dir == "" {
home, err := os.UserHomeDir()
if err != nil {
return ""
}
dir = filepath.Join(home, ".config", "correx")
}
return filepath.Join(dir, "tui-prefs.json")
}
// loadPrefs reads the whole prefs file (best-effort: a missing or corrupt file yields the
// zero value, i.e. everything shown / actions visible).
func loadPrefs() prefs {
var p prefs
path := prefsPath()
if path == "" {
return p
}
if data, err := os.ReadFile(path); err == nil {
_ = json.Unmarshal(data, &p)
}
return p
}
// savePrefs writes the whole prefs file (best-effort; ignores IO errors so a read-only home
// never crashes the UI). Callers load, mutate one field, then save so nothing is clobbered.
func savePrefs(p prefs) {
path := prefsPath()
if path == "" {
return
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return
}
if data, err := json.MarshalIndent(p, "", " "); err == nil {
_ = os.WriteFile(path, data, 0o644)
}
}
// loadStatusbarHidden reads the persisted hidden-segment set (best-effort: a missing or
// corrupt file yields an empty set, i.e. everything shown).
func loadStatusbarHidden() map[string]bool {
out := map[string]bool{}
for _, id := range loadPrefs().StatusbarHidden {
out[id] = true
}
return out
}
// saveStatusbarHidden writes the hidden set back to disk, preserving the other prefs fields.
// Order follows statusSegments for a stable file.
func saveStatusbarHidden(hidden map[string]bool) {
var ids []string
for _, seg := range statusSegments {
if hidden[seg.id] {
ids = append(ids, seg.id)
}
}
p := loadPrefs()
p.StatusbarHidden = ids
savePrefs(p)
}
// saveInlineActionsHidden persists the inline-action-rows toggle, preserving other prefs fields.
func saveInlineActionsHidden(hidden bool) {
p := loadPrefs()
p.InlineActionsHidden = hidden
savePrefs(p)
}
+13 -13
View File
@@ -3,8 +3,8 @@ package app
import (
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/correx/tui-go/internal/protocol"
)
@@ -24,7 +24,7 @@ func (m *Model) proposeInitState() {
// --- key handling ---
func (m Model) handleProposeKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
func (m Model) handleProposeKey(k keyMsg) (tea.Model, tea.Cmd) {
s := m.session(m.selectedID)
if s == nil || s.Propose == nil {
return m, nil
@@ -34,15 +34,15 @@ func (m Model) handleProposeKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
}
n := len(s.Propose.Candidates) // index n == the custom slot
switch k.Type {
case tea.KeyEsc:
case keyEsc:
m.proposeDismissed = true
case tea.KeyUp:
case keyUp:
m.proposeCursor = clampInt(m.proposeCursor-1, 0, n)
case tea.KeyDown:
case keyDown:
m.proposeCursor = clampInt(m.proposeCursor+1, 0, n)
case tea.KeyEnter, tea.KeySpace:
case keyEnter, keySpace:
return m.proposeSubmit()
case tea.KeyRunes:
case keyRunes:
switch string(k.Runes) {
case "k":
m.proposeCursor = clampInt(m.proposeCursor-1, 0, n)
@@ -56,17 +56,17 @@ func (m Model) handleProposeKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil
}
func (m Model) handleProposeTyping(k tea.KeyMsg) (tea.Model, tea.Cmd) {
func (m Model) handleProposeTyping(k keyMsg) (tea.Model, tea.Cmd) {
switch k.Type {
case tea.KeyEsc:
case keyEsc:
m.proposeTyping = false
case tea.KeyEnter:
case keyEnter:
return m.proposeSubmit() // commit the custom answer and send it as chat
case tea.KeyBackspace:
case keyBackspace:
if len(m.proposeText) > 0 {
m.proposeText = m.proposeText[:len(m.proposeText)-1]
}
case tea.KeyRunes, tea.KeySpace:
case keyRunes, keySpace:
m.proposeText += string(k.Runes)
}
return m, nil
+8 -9
View File
@@ -5,7 +5,6 @@ import (
"strings"
"testing"
tea "github.com/charmbracelet/bubbletea"
"github.com/correx/tui-go/internal/protocol"
"github.com/correx/tui-go/internal/ws"
)
@@ -47,8 +46,8 @@ func TestProposeEntersStateAndRenders(t *testing.T) {
func TestProposePickLaunchesChosenWorkflow(t *testing.T) {
m, client := proposeModel()
// move cursor to the second candidate (role_pipeline) and launch it
m = applyProposeKey(m, tea.KeyMsg{Type: tea.KeyDown})
m = applyProposeKey(m, tea.KeyMsg{Type: tea.KeyEnter})
m = applyProposeKey(m, keyMsg{Type: keyDown})
m = applyProposeKey(m, keyMsg{Type: keyEnter})
if s := m.session("s1"); s == nil || s.Propose != nil {
t.Fatalf("expected proposal cleared after launch")
@@ -67,7 +66,7 @@ func TestProposePickLaunchesChosenWorkflow(t *testing.T) {
func TestProposeFirstCandidateIsDefault(t *testing.T) {
m, client := proposeModel()
m = applyProposeKey(m, tea.KeyMsg{Type: tea.KeyEnter}) // no nav → first candidate
m = applyProposeKey(m, keyMsg{Type: keyEnter}) // no nav → first candidate
wf, _ := decodeStart(t, client)
if wf != "research" {
t.Fatalf("expected first candidate launched by default, got %q", wf)
@@ -76,14 +75,14 @@ func TestProposeFirstCandidateIsDefault(t *testing.T) {
func TestProposeCustomAnswerContinuesChat(t *testing.T) {
m, client := proposeModel()
m = applyProposeKey(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("e")})
m = applyProposeKey(m, keyMsg{Type: keyRunes, Runes: []rune("e")})
if !m.proposeTyping {
t.Fatalf("expected typing mode after 'e'")
}
for _, r := range "do it manually" {
m = applyProposeKey(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
m = applyProposeKey(m, keyMsg{Type: keyRunes, Runes: []rune{r}})
}
m = applyProposeKey(m, tea.KeyMsg{Type: tea.KeyEnter})
m = applyProposeKey(m, keyMsg{Type: keyEnter})
if s := m.session("s1"); s == nil || s.Propose != nil {
t.Fatalf("expected proposal cleared after custom answer")
@@ -109,7 +108,7 @@ func TestProposeCustomAnswerContinuesChat(t *testing.T) {
func TestProposeEscDismisses(t *testing.T) {
m, _ := proposeModel()
m = applyProposeKey(m, tea.KeyMsg{Type: tea.KeyEsc})
m = applyProposeKey(m, keyMsg{Type: keyEsc})
if m.displayState() != StateInSession {
t.Fatalf("expected StateInSession after esc, got %v", m.displayState())
}
@@ -118,7 +117,7 @@ func TestProposeEscDismisses(t *testing.T) {
}
}
func applyProposeKey(m Model, k tea.KeyMsg) Model {
func applyProposeKey(m Model, k keyMsg) Model {
updated, _ := m.handleProposeKey(k)
return updated.(Model)
}
@@ -0,0 +1,671 @@
package app
import (
"bufio"
"os"
"regexp"
"strings"
"testing"
"github.com/correx/tui-go/internal/protocol"
"github.com/correx/tui-go/internal/ws"
)
// render_matrix_test.go is the per-event-type golden render matrix (BACKLOG §E §2):
// one case per server event/entry kind the TUI handles. Each case drives a
// representative protocol.ServerMessage through the *production* path (applyServer),
// renders the surface it lands on (View / a card / a modal / the router transcript),
// strips ANSI, and asserts the render carries that kind's defining, stable text.
//
// Goldens are inline expected substrings rather than golden files: it matches the
// module's existing render-test convention (clarcard_test, ideacard_test, …), needs no
// -update dance, and pins the *meaning* of each render (the tool name, stage id, status
// label, card title) without coupling to exact lipgloss styling or timestamps.
//
// The coverage guard (TestRenderMatrixCoversEveryEventType) is the load-bearing part:
// it parses every protocol.Type* constant and fails if a new event kind is added
// without either a matrix case or an explicit non-rendering classification.
// matrixSurface selects which render surface a case asserts against — each is a real
// production render entry point, fed off the same Model that applyServer mutated.
type matrixSurface int
const (
surfaceView matrixSurface = iota // full immediate-mode frame, View()
surfaceEvents // right-panel event-stream rows (eventRows)
surfaceRouter // left transcript rows (routerRows)
surfaceApprovalBand // docked approval band (renderApprovalBand)
surfaceClarModal // clarification modal
surfaceProposeModal // workflow-propose modal
surfaceIdeasModal // idea board overlay
surfaceStatsModal // session-stats overlay
surfaceConfigModal // config editor overlay
surfaceArtifactsModal // artifact viewer overlay
surfaceModelsModal // model swap overlay
surfaceStatusBar // top status bar (renderStatus)
)
// fixedNow is a deterministic timestamp used for every event carrying OccurredAt. The
// matrix asserts only event type/detail text, never the rendered clock — formatTime is
// local (machine-TZ dependent), so the time string is intentionally not part of any golden.
const fixedNow int64 = 1_700_000_000_000
// matrixCase is one event/entry kind in the matrix.
type matrixCase struct {
name string // human label / subtest name
kinds []string // protocol.Type* values this case exercises
build func() protocol.ServerMessage // a representative frame
surface matrixSurface // where its render lands
// prep mutates the model before applyServer (e.g. select + enter a session so an
// in-session surface is reachable, or open the overlay an *.list reply fills).
prep func(m *Model)
want []string // ANSI-stripped substrings the render must contain
}
// newMatrixModel builds a deterministic Model: fixed width/theme, an entered session,
// an unconnected ws client (Send buffers, never dials).
func newMatrixModel() Model {
m := NewModel(ws.New("", 0))
m.width, m.height = 120, 40
m.theme = NewTheme(SoftBlue)
m.connected = true
m.selectedID = "s1"
m.sessionEntered = true
m.sessions = []Session{{ID: "s1", Status: "ACTIVE", Name: "healthcheck", LastEventAt: fixedNow}}
return m
}
// renderSurface renders the requested surface off m and strips ANSI.
func renderSurface(m Model, s matrixSurface) string {
var raw string
switch s {
case surfaceView:
raw = m.render()
case surfaceEvents:
// Render the event-stream rows at full panel width so a row's detail isn't
// clipped at the slim right-panel edge (the production builder, just wide).
raw = strings.Join(m.eventRows(110, 40), "\n")
case surfaceRouter:
raw = strings.Join(m.routerRows(100, 30), "\n")
case surfaceApprovalBand:
raw = m.renderApprovalBand(m.approvalBandHeight())
case surfaceClarModal:
raw = m.clarificationModal()
case surfaceProposeModal:
raw = m.proposeModal()
case surfaceIdeasModal:
raw = m.ideasModal()
case surfaceStatsModal:
raw = m.statsModal()
case surfaceConfigModal:
raw = m.configModal()
case surfaceArtifactsModal:
raw = m.artifactsModal()
case surfaceModelsModal:
raw = m.modelsModal()
case surfaceStatusBar:
raw = m.renderStatus()
}
return stripANSI(raw)
}
// matrixCases is the matrix: one entry per event/entry kind the renderer handles.
func matrixCases() []matrixCase {
str := func(s string) *string { return &s }
return []matrixCase{
// --- session lifecycle (status bar + narration feed) ---
{
name: "session.announced",
kinds: []string{protocol.TypeSessionAnnounced},
surface: surfaceStatusBar,
build: func() protocol.ServerMessage { return msg(protocol.TypeSessionAnnounced, withWF("healthcheck")) },
want: []string{"healthcheck", "ACTIVE"},
},
{
name: "session.paused",
kinds: []string{protocol.TypeSessionPaused},
surface: surfaceRouter,
build: func() protocol.ServerMessage { return msg(protocol.TypeSessionPaused) },
want: []string{"paused"},
},
{
name: "session.resumed",
kinds: []string{protocol.TypeSessionResumed},
surface: surfaceRouter,
build: func() protocol.ServerMessage { return msg(protocol.TypeSessionResumed) },
want: []string{"resumed"},
},
{
name: "session.completed",
kinds: []string{protocol.TypeSessionCompleted},
surface: surfaceRouter,
build: func() protocol.ServerMessage { return msg(protocol.TypeSessionCompleted) },
want: []string{"workflow complete"},
},
{
name: "session.failed",
kinds: []string{protocol.TypeSessionFailed},
surface: surfaceRouter,
build: func() protocol.ServerMessage { return msg(protocol.TypeSessionFailed, withReason("schema mismatch")) },
want: []string{"workflow failed", "schema mismatch"},
},
// --- chat / router transcript ---
{
name: "chat.turn (router)",
kinds: []string{protocol.TypeChatTurn},
surface: surfaceRouter,
build: func() protocol.ServerMessage {
return msg(protocol.TypeChatTurn, func(m *protocol.ServerMessage) {
m.Role, m.Content = "ROUTER", "running the healthcheck now"
})
},
want: []string{"running the healthcheck now"},
},
{
name: "router.narration",
kinds: []string{protocol.TypeRouterNarration},
surface: surfaceRouter,
build: func() protocol.ServerMessage {
return msg(protocol.TypeRouterNarration, func(m *protocol.ServerMessage) {
m.Content = "moving on to validation"
})
},
want: []string{"moving on to validation", "◆"},
},
// --- stage lifecycle (event stream rows) ---
{
name: "stage.started",
kinds: []string{protocol.TypeStageStarted},
surface: surfaceEvents,
build: func() protocol.ServerMessage { return msg(protocol.TypeStageStarted, withStage("write_script")) },
want: []string{"StageStarted", "write_script"},
},
{
name: "stage.completed",
kinds: []string{protocol.TypeStageCompleted},
surface: surfaceEvents,
build: func() protocol.ServerMessage { return msg(protocol.TypeStageCompleted, withStage("write_script")) },
want: []string{"StageCompleted", "write_script"},
},
{
name: "stage.failed",
kinds: []string{protocol.TypeStageFailed},
surface: surfaceEvents,
build: func() protocol.ServerMessage { return msg(protocol.TypeStageFailed, withStage("generate")) },
want: []string{"StageFailed", "generate"},
},
// --- inference lifecycle (event stream rows) ---
{
name: "inference.started",
kinds: []string{protocol.TypeInferenceStarted},
surface: surfaceEvents,
build: func() protocol.ServerMessage { return msg(protocol.TypeInferenceStarted, withStage("plan")) },
want: []string{"InferenceStarted", "plan"},
},
{
name: "inference.completed",
kinds: []string{protocol.TypeInferenceDone},
surface: surfaceEvents,
build: func() protocol.ServerMessage {
return msg(protocol.TypeInferenceDone, withStage("plan"), func(m *protocol.ServerMessage) { m.Summary = "ok" })
},
want: []string{"InferenceCompleted", "plan"},
},
{
name: "inference.timed_out",
kinds: []string{protocol.TypeInferenceTimeout},
surface: surfaceEvents,
build: func() protocol.ServerMessage { return msg(protocol.TypeInferenceTimeout, withStage("plan")) },
want: []string{"InferenceTimedOut", "plan"},
},
{
name: "inference.failed",
kinds: []string{protocol.TypeInferenceFailed},
surface: surfaceEvents,
build: func() protocol.ServerMessage {
return msg(protocol.TypeInferenceFailed, withStage("plan"), withReason("oom"))
},
want: []string{"InferenceFailed", "plan", "oom"},
},
{
name: "inference.retry",
kinds: []string{protocol.TypeInferenceRetry},
surface: surfaceEvents,
build: func() protocol.ServerMessage {
return msg(protocol.TypeInferenceRetry, withStage("plan"), func(m *protocol.ServerMessage) {
m.AttemptNumber, m.MaxAttempts, m.FailureReason = 2, 3, "rate limited"
})
},
want: []string{"RetryAttempted", "plan", "2/3", "rate limited"},
},
// --- tools ---
{
name: "tool.started",
kinds: []string{protocol.TypeToolStarted},
surface: surfaceStatusBar, // started shows as the active spinner; record lands in s.Tools
build: func() protocol.ServerMessage { return msg(protocol.TypeToolStarted, withTool("file_write")) },
want: []string{"active"},
},
{
name: "tool.completed",
kinds: []string{protocol.TypeToolCompleted},
surface: surfaceEvents,
build: func() protocol.ServerMessage {
return msg(protocol.TypeToolCompleted, withTool("file_write"), func(m *protocol.ServerMessage) { m.Summary = "wrote 2 files" })
},
want: []string{"ToolCompleted", "file_write"},
},
{
name: "tool.failed",
kinds: []string{protocol.TypeToolFailed},
surface: surfaceView,
prep: func(m *Model) {
// failed marks an already-started tool record; seed one so the status flips.
s := m.session("s1")
s.Tools = []ToolRecord{{Name: "file_write", Status: ToolStarted}}
},
build: func() protocol.ServerMessage { return msg(protocol.TypeToolFailed, withTool("file_write")) },
// tool.failed is a state-only mutation (no event row, no card); assert it
// renders the in-session frame without crashing and keeps the session header.
want: []string{"healthcheck"},
},
{
name: "tool.rejected",
kinds: []string{protocol.TypeToolRejected},
surface: surfaceView,
prep: func(m *Model) {
s := m.session("s1")
s.Tools = []ToolRecord{{Name: "shell_exec", Status: ToolStarted}}
},
build: func() protocol.ServerMessage { return msg(protocol.TypeToolRejected, withTool("shell_exec")) },
want: []string{"healthcheck"},
},
{
name: "tool.assessed",
kinds: []string{protocol.TypeToolAssessed},
surface: surfaceEvents,
build: func() protocol.ServerMessage {
return msg(protocol.TypeToolAssessed, withTool("file_write"), func(m *protocol.ServerMessage) {
m.Disposition = "BLOCK"
m.AssessedIssues = []protocol.AssessedIssueDto{{Code: "PATH_ESCAPE", Message: "outside workspace", Severity: "HIGH"}}
})
},
want: []string{"ToolAssessed", "BLOCK", "PATH_ESCAPE"},
},
// --- artifacts / plan (event stream rows) ---
{
name: "artifact.created",
kinds: []string{protocol.TypeArtifactCreated},
surface: surfaceEvents,
build: func() protocol.ServerMessage {
return msg(protocol.TypeArtifactCreated, func(m *protocol.ServerMessage) { m.ArtifactID = "art-7" })
},
want: []string{"ArtifactCreated", "art-7"},
},
{
name: "artifact.validated",
kinds: []string{protocol.TypeArtifactValid},
surface: surfaceEvents,
build: func() protocol.ServerMessage {
return msg(protocol.TypeArtifactValid, func(m *protocol.ServerMessage) { m.ArtifactID = "art-7" })
},
want: []string{"ArtifactValidated", "art-7"},
},
{
name: "plan.locked",
kinds: []string{protocol.TypePlanLocked},
surface: surfaceEvents,
build: func() protocol.ServerMessage {
return msg(protocol.TypePlanLocked, func(m *protocol.ServerMessage) { m.StageIDs = []string{"plan", "build", "verify"} })
},
want: []string{"PlanLocked", "plan", "build", "verify"},
},
// --- workspace bind (status bar) ---
{
name: "session.workspace_bound",
kinds: []string{protocol.TypeWorkspaceBound},
surface: surfaceStatusBar,
build: func() protocol.ServerMessage {
return msg(protocol.TypeWorkspaceBound, func(m *protocol.ServerMessage) { m.WorkspaceRoot = "/tmp/corx-ws" })
},
want: []string{"corx-ws"},
},
// --- approval gate (docked band card) ---
{
name: "approval.required",
kinds: []string{protocol.TypeApprovalRequired},
surface: surfaceApprovalBand,
build: func() protocol.ServerMessage {
return msg(protocol.TypeApprovalRequired, withTool("file_write"), func(m *protocol.ServerMessage) {
m.RequestID, m.Tier = "req-1", "T3"
m.Preview = str("--- a/x\n+++ b/x\n@@ -1 +1 @@\n-old\n+new\n")
m.RiskSummary = &protocol.RiskSummaryDto{Level: "HIGH", Rationale: []string{"[PATH_OUTSIDE_WORKSPACE] /etc/hosts"}}
})
},
want: []string{"file_write", "T3", "HIGH", "PATH_OUTSIDE_WORKSPACE"},
},
{
name: "approval.resolved",
kinds: []string{protocol.TypeApprovalResolved},
surface: surfaceEvents,
build: func() protocol.ServerMessage {
return msg(protocol.TypeApprovalResolved, func(m *protocol.ServerMessage) { m.Outcome, m.Reason = "APPROVED", "operator ok" })
},
want: []string{"ApprovalResolved", "APPROVED", "operator ok"},
},
// --- clarification (modal card) ---
{
name: "clarification.required",
kinds: []string{protocol.TypeClarification},
surface: surfaceClarModal,
build: func() protocol.ServerMessage {
return msg(protocol.TypeClarification, withStage("analyst"), func(m *protocol.ServerMessage) {
m.RequestID = "req-1"
m.Questions = []protocol.ClarificationQuestionDto{
{ID: "stack", Prompt: "Which stack?", Options: []string{"React", "Vue"}, Header: "Stack"},
}
})
},
want: []string{"clarifying questions", "Which stack?", "React", "Vue", "Stack"},
},
// --- workflow proposal (modal card) ---
{
name: "workflow.proposed",
kinds: []string{protocol.TypeWorkflowProposed},
surface: surfaceProposeModal,
build: func() protocol.ServerMessage {
return msg(protocol.TypeWorkflowProposed, func(m *protocol.ServerMessage) {
m.ProposalID, m.Prompt, m.OriginalRequest = "prop-1", "Run one of these?", "scan the repo"
m.Candidates = []protocol.ProposedWorkflowDto{
{WorkflowID: "research", Reason: "gather + report"},
{WorkflowID: "role_pipeline", Reason: "full build"},
}
})
},
want: []string{"workflow suggestion", "Run one of these?", "research", "gather + report", "role_pipeline"},
},
// --- idea board (overlay; cross-session, no session scope) ---
{
name: "idea.list",
kinds: []string{protocol.TypeIdeaList},
surface: surfaceIdeasModal,
prep: func(m *Model) { m.overlay = OverlayIdeas },
build: func() protocol.ServerMessage {
return protocol.ServerMessage{Type: protocol.TypeIdeaList, Ideas: []protocol.IdeaDto{
{IdeaID: "i1", Text: "cache the repo map", CapturedAtMs: 1000},
}}
},
want: []string{"ideas", "cache the repo map"},
},
// --- session stats (overlay) ---
{
name: "session.stats",
kinds: []string{protocol.TypeSessionStats},
surface: surfaceStatsModal,
prep: func(m *Model) { m.overlay = OverlayStats; m.statsFor = "s1" },
build: func() protocol.ServerMessage {
return protocol.ServerMessage{Type: protocol.TypeSessionStats, SessionID: "s1", Stats: sampleStats()}
},
want: []string{"file_write", "read_file"},
},
// --- config snapshot (overlay) ---
{
name: "config.snapshot",
kinds: []string{protocol.TypeConfigSnapshot},
surface: surfaceConfigModal,
prep: func(m *Model) { m.overlay = OverlayConfig },
build: func() protocol.ServerMessage {
return protocol.ServerMessage{Type: protocol.TypeConfigSnapshot, ConfigFields: []protocol.ConfigFieldDto{
{Key: "router.model", Type: "string", Value: "qwen2.5-coder"},
}}
},
want: []string{"router.model", "qwen2.5-coder"},
},
// --- artifact list (overlay) ---
{
name: "artifact.list",
kinds: []string{protocol.TypeArtifactList},
surface: surfaceArtifactsModal,
prep: func(m *Model) { m.overlay = OverlayArtifacts; m.artifactsFor = "s1" },
build: func() protocol.ServerMessage {
return protocol.ServerMessage{Type: protocol.TypeArtifactList, SessionID: "s1", Artifacts: []protocol.ArtifactDto{
{ArtifactID: "art-7", StageID: "plan", SchemaVersion: 1, Phase: "PLAN", Content: str("{\"ok\":true}")},
}}
},
want: []string{"art-7"},
},
// --- model list / changed (overlay + status chrome) ---
{
name: "model.list",
kinds: []string{protocol.TypeModelList},
surface: surfaceModelsModal,
prep: func(m *Model) { m.overlay = OverlayModels },
build: func() protocol.ServerMessage {
return protocol.ServerMessage{Type: protocol.TypeModelList, Models: []string{"qwen2.5-coder", "llama3"}, Current: "qwen2.5-coder"}
},
want: []string{"qwen2.5-coder", "llama3"},
},
{
name: "model.changed",
kinds: []string{protocol.TypeModelChanged},
surface: surfaceStatusBar,
build: func() protocol.ServerMessage {
return protocol.ServerMessage{Type: protocol.TypeModelChanged, ModelID: "llama3-swapped", Loaded: true}
},
want: []string{"llama3-swapped"},
},
// --- provider status (status bar model + locality) ---
{
name: "provider.status_changed",
kinds: []string{protocol.TypeProviderStatus},
surface: surfaceStatusBar,
build: func() protocol.ServerMessage {
return protocol.ServerMessage{Type: protocol.TypeProviderStatus, ProviderID: "llama-cpp:qwen"}
},
want: []string{"llama-cpp:qwen", "local"},
},
// --- resource gauge (status bar) ---
{
name: "resource.status",
kinds: []string{protocol.TypeResourceStatus},
surface: surfaceStatusBar,
prep: func(m *Model) { m.selectedID = ""; m.sessionEntered = false }, // gauge shows on the idle bar
build: func() protocol.ServerMessage {
used, total, util := int64(4096), int64(8192), 42
return protocol.ServerMessage{Type: protocol.TypeResourceStatus,
GpuMemoryUsedMb: &used, GpuMemoryTotalMb: &total, GpuUtilizationPct: &util}
},
want: []string{"VRAM 4096/8192M", "GPU 42%"},
},
// --- workflow list → idle launch target (Tab-selected, shown at the input) ---
{
name: "workflow.list",
kinds: []string{protocol.TypeWorkflowList},
surface: surfaceView,
prep: func(m *Model) { m.selectedID = ""; m.sessionEntered = false; m.launcherWf = 1 },
build: func() protocol.ServerMessage {
return protocol.ServerMessage{Type: protocol.TypeWorkflowList, Workflows: []protocol.WorkflowDto{
{WorkflowID: "healthcheck", Description: "kick the tires"},
}}
},
want: []string{"healthcheck"},
},
// --- session snapshot (reopen: rebuilds a session from the recent-events tail) ---
{
name: "session_snapshot",
kinds: []string{protocol.TypeSessionSnapshot},
surface: surfaceEvents,
prep: func(m *Model) { m.sessions = nil; m.selectedID = "snap1"; m.sessionEntered = true },
build: func() protocol.ServerMessage {
return protocol.ServerMessage{
Type: protocol.TypeSessionSnapshot, SessionID: "snap1", WorkflowID: "refactor",
State: &protocol.SessionStateDto{Status: "PAUSED"},
RecentEvents: []protocol.EventEntryDto{{Timestamp: fixedNow, Type: "StageStarted", Detail: "plan"}},
}
},
want: []string{"StageStarted", "plan"},
},
// --- stage tool manifest (declared, not-yet-run tools — state only) ---
{
name: "stage.tool_manifest",
kinds: []string{protocol.TypeStageManifest},
surface: surfaceView,
build: func() protocol.ServerMessage {
return protocol.ServerMessage{Type: protocol.TypeStageManifest, SessionID: "s1", Stages: []protocol.StageToolDecl{
{StageID: "plan", Tools: []protocol.ToolDecl{{Name: "read_file", Tier: 1}}},
}}
},
// manifest seeds s.ToolsByStage (surfaced by the tool palette, not the main
// frame); assert the in-session frame still renders cleanly with the session.
want: []string{"healthcheck"},
},
// --- unknown / future event: raw fallback row, never dropped (§2) ---
{
name: "unknown event (raw fallback)",
kinds: []string{"some.future.event"},
surface: surfaceEvents,
build: func() protocol.ServerMessage {
return protocol.ServerMessage{Type: "some.future.event", SessionID: "s1", OccurredAt: fixedNow}
},
want: []string{"some.future.event", "raw"},
},
}
}
// --- frame builders (keep cases terse) ---
type msgOpt func(*protocol.ServerMessage)
func msg(typ string, opts ...msgOpt) protocol.ServerMessage {
m := protocol.ServerMessage{Type: typ, SessionID: "s1", OccurredAt: fixedNow}
for _, o := range opts {
o(&m)
}
return m
}
func withWF(id string) msgOpt { return func(m *protocol.ServerMessage) { m.WorkflowID = id } }
func withStage(id string) msgOpt { return func(m *protocol.ServerMessage) { m.StageID = id } }
func withTool(name string) msgOpt { return func(m *protocol.ServerMessage) { m.ToolName = name } }
func withReason(r string) msgOpt { return func(m *protocol.ServerMessage) { m.Reason = r } }
// TestRenderMatrix is the table-driven golden render matrix: every event/entry kind,
// driven through applyServer, asserts its render carries its defining stable text.
func TestRenderMatrix(t *testing.T) {
for _, c := range matrixCases() {
t.Run(c.name, func(t *testing.T) {
m := newMatrixModel()
if c.prep != nil {
c.prep(&m)
}
m.applyServer(c.build())
out := renderSurface(m, c.surface)
for _, want := range c.want {
if !strings.Contains(out, want) {
t.Fatalf("render for %q missing %q\n--- visible ---\n%s", c.name, want, out)
}
}
})
}
}
// nonRenderingEventTypes are protocol.Type* constants the TUI handles but that have no
// per-kind render of their own — they are explicitly classified here so the coverage
// guard stays exhaustive. Adding a new Type* constant forces a choice: give it a matrix
// case, or list it here with a reason. Either way the guard can't silently pass.
var nonRenderingEventTypes = map[string]string{
protocol.TypeSnapshotComplete: "control frame: ends the snapshot-buffering phase; no render",
protocol.TypeRouterResponse: "recognized no-op: superseded by chat.turn on the global stream",
protocol.TypeProtocolError: "transport diagnostic: explicit no-op, not surfaced",
protocol.TypeFileList: "query reply: populates the @ file picker overlay, not the transcript",
protocol.TypeGrantList: "query reply: populates the standing-grants overlay, not the transcript",
protocol.TypeHealthChecks: "query reply: populates the health-checks overlay, not the transcript",
}
// TestRenderMatrixCoversEveryEventType is the load-bearing matrix guarantee: it scans
// every protocol.Type* constant declared in protocol.go and fails if any is neither
// covered by a matrix case nor explicitly classified as non-rendering. A new event kind
// added to the protocol without a matching render assertion breaks this test.
func TestRenderMatrixCoversEveryEventType(t *testing.T) {
declared := declaredEventTypes(t)
if len(declared) < 30 {
t.Fatalf("expected to scan ~40 protocol Type* constants, found only %d — parser drift?", len(declared))
}
covered := map[string]bool{}
for _, c := range matrixCases() {
for _, k := range c.kinds {
covered[k] = true
}
}
var missing []string
for typ := range declared {
if covered[typ] {
continue
}
if _, ok := nonRenderingEventTypes[typ]; ok {
continue
}
missing = append(missing, typ)
}
if len(missing) > 0 {
t.Fatalf("event types with no matrix case and no non-rendering classification: %v\n"+
"add a case to matrixCases() (preferred) or list it in nonRenderingEventTypes with a reason", missing)
}
// Reverse guard: every classified non-rendering type must still be a real constant
// (catches a typo or a removed constant rotting the allowlist).
for typ := range nonRenderingEventTypes {
if !declared[typ] {
t.Errorf("nonRenderingEventTypes lists %q which is not a declared protocol Type* value", typ)
}
}
}
// declaredEventTypes parses protocol.go and returns the value of every Type* string
// constant (e.g. "stage.started"). Deriving the authoritative set from source — rather
// than a hand-kept list — is what makes the coverage guard catch *new* event kinds.
func declaredEventTypes(t *testing.T) map[string]bool {
t.Helper()
const src = "../protocol/protocol.go"
f, err := os.Open(src)
if err != nil {
t.Fatalf("open %s: %v", src, err)
}
defer f.Close()
// Matches lines like: TypeStageStarted = "stage.started"
re := regexp.MustCompile(`^\s*Type[A-Za-z0-9]+\s*=\s*"([^"]+)"`)
out := map[string]bool{}
sc := bufio.NewScanner(f)
for sc.Scan() {
if mm := re.FindStringSubmatch(sc.Text()); mm != nil {
out[mm[1]] = true
}
}
if err := sc.Err(); err != nil {
t.Fatalf("scan %s: %v", src, err)
}
return out
}
+120 -12
View File
@@ -22,8 +22,11 @@ func containsFold(haystack, needle string) bool {
return strings.Contains(strings.ToLower(haystack), strings.ToLower(needle))
}
// formatTime renders an event/activity instant as a local wall-clock time, matching the
// operator's clock (and the local date shown in the session list). It was previously forced
// to UTC, so every event timestamp read hours off the user's actual time.
func formatTime(epochMillis int64) string {
return time.UnixMilli(epochMillis).UTC().Format("15:04:05")
return time.UnixMilli(epochMillis).Format("15:04:05")
}
// inferCategory maps an event type string to a display category, matching the
@@ -125,7 +128,7 @@ func (m *Model) applyServer(msg protocol.ServerMessage) {
case protocol.TypeSessionResumed:
if s := m.session(msg.SessionID); s != nil {
s.Status = "ACTIVE"
s.Pending = nil
s.clearApprovals()
s.LastEventAt = nowMillis()
}
case protocol.TypeSessionCompleted:
@@ -134,6 +137,7 @@ func (m *Model) applyServer(msg protocol.ServerMessage) {
s.Active = false
s.Clar = nil
s.Propose = nil
s.clearApprovals()
}
case protocol.TypeSessionFailed:
m.touch(msg.SessionID, "FAILED")
@@ -141,6 +145,7 @@ func (m *Model) applyServer(msg protocol.ServerMessage) {
s.Active = false
s.Clar = nil
s.Propose = nil
s.clearApprovals()
}
case protocol.TypeStageStarted:
if s := m.session(msg.SessionID); s != nil {
@@ -196,6 +201,8 @@ func (m *Model) applyServer(msg protocol.ServerMessage) {
}
s.LastEventAt = nowMillis()
}
// Tool-start is not surfaced inline (it doubles up with the ✓/✎ result row); it
// stays in the tool list + EVENTS panel.
case protocol.TypeToolCompleted:
if s := m.session(msg.SessionID); s != nil {
s.Active = false
@@ -204,7 +211,13 @@ func (m *Model) applyServer(msg protocol.ServerMessage) {
s.addEvent(msg.OccurredAt, "ToolCompleted", msg.ToolName)
}
if msg.Diff != nil && *msg.Diff != "" {
// A diff = a write/edit: name the file + the line delta, then keep the
// existing collapsed diff row (^x opens the full diff).
path, add, del := diffSummary(*msg.Diff)
m.appendAction(msg.SessionID, "✎", "wrote "+path+countSuffix(add, del))
m.appendRouter(msg.SessionID, RouterEntry{Role: "tool", Content: *msg.Diff})
} else {
m.appendAction(msg.SessionID, "✓", actionToolText(msg.ToolName, msg.Summary))
}
case protocol.TypeToolAssessed:
if s := m.session(msg.SessionID); s != nil {
@@ -222,11 +235,13 @@ func (m *Model) applyServer(msg protocol.ServerMessage) {
s.markTool(msg.ToolName, ToolFailed)
s.LastEventAt = msg.OccurredAt
}
m.appendAction(msg.SessionID, "✗", actionToolText(msg.ToolName+" failed", msg.Reason))
case protocol.TypeToolRejected:
if s := m.session(msg.SessionID); s != nil {
s.markTool(msg.ToolName, ToolRejected)
s.LastEventAt = nowMillis()
}
m.appendAction(msg.SessionID, "✕", actionToolText(msg.ToolName+" blocked", msg.Reason))
case protocol.TypeArtifactCreated:
if s := m.session(msg.SessionID); s != nil {
s.addEvent(nowMillis(), "ArtifactCreated", msg.ArtifactID)
@@ -252,13 +267,25 @@ func (m *Model) applyServer(msg protocol.ServerMessage) {
m.onWorkflowProposed(msg)
case protocol.TypeApprovalResolved:
if s := m.session(msg.SessionID); s != nil {
s.Pending = nil
// The resolved gate names no tool on the wire; recover it from the pending
// queue before dropping the gate, for the inline row.
tool := ""
for _, a := range s.PendingQueue {
if a.RequestID == msg.RequestID {
tool = a.ToolName
break
}
}
// Drop just the resolved gate; any others stay queued and the band
// advances to the next rather than vanishing entirely.
s.removeApproval(msg.RequestID)
detail := msg.Outcome
if msg.Reason != "" {
detail += " — " + msg.Reason
}
s.addEvent(msg.OccurredAt, "ApprovalResolved", detail)
s.LastEventAt = nowMillis()
m.appendAction(msg.SessionID, approvalIcon(msg.Outcome), approvalActionText(msg.Outcome, tool, msg.Reason))
}
case protocol.TypeSessionSnapshot:
m.onSnapshot(msg)
@@ -325,6 +352,19 @@ func (m *Model) applyServer(msg protocol.ServerMessage) {
case protocol.TypeHealthChecks:
m.health = msg.Health
m.healthLoading = false
case protocol.TypeFileList:
m.files = msg.Paths
m.filesFor = msg.SessionID
m.filesLoading = false
if m.fileIndex >= len(m.filteredFiles()) {
m.fileIndex = 0
}
case protocol.TypeGrantList:
m.grants = msg.Grants
m.grantsLoading = false
if m.grantIndex >= len(m.grants) {
m.grantIndex = 0
}
case protocol.TypeConfigSnapshot:
m.configFields = msg.ConfigFields
m.configRestart = msg.ConfigRestartRequired
@@ -370,13 +410,81 @@ func sessionIDOf(msg protocol.ServerMessage) string {
protocol.TypeWorkflowList, protocol.TypeRouterResponse,
protocol.TypeModelChanged, protocol.TypeModelList, protocol.TypeResourceStatus,
protocol.TypeArtifactList, protocol.TypeConfigSnapshot, protocol.TypeSessionStats,
protocol.TypeIdeaList, protocol.TypeHealthChecks:
protocol.TypeIdeaList, protocol.TypeHealthChecks,
protocol.TypeFileList, protocol.TypeGrantList:
return ""
default:
return msg.SessionID
}
}
// --- inline action-row helpers (external-feedback events surfaced in OUTPUT) ---
// diffSummary extracts the written path and +/- line counts from a unified diff for the
// inline write row. Falls back to "file" when no `+++` header is present.
func diffSummary(diff string) (path string, added, removed int) {
path = "file"
for _, ln := range strings.Split(diff, "\n") {
switch {
case strings.HasPrefix(ln, "+++ "):
p := strings.TrimSpace(strings.TrimPrefix(ln, "+++ "))
p = strings.TrimPrefix(p, "b/")
if p != "" && p != "/dev/null" {
path = p
}
case strings.HasPrefix(ln, "+") && !strings.HasPrefix(ln, "+++ "):
added++
case strings.HasPrefix(ln, "-") && !strings.HasPrefix(ln, "--- "):
removed++
}
}
return path, added, removed
}
// countSuffix renders the " (+a b)" delta, or "" when nothing changed.
func countSuffix(added, removed int) string {
if added == 0 && removed == 0 {
return ""
}
return " (+" + itoa(added) + " " + itoa(removed) + ")"
}
// actionToolText joins a tool label with a short, clipped result summary.
func actionToolText(label, summary string) string {
s := strings.TrimSpace(summary)
if s == "" {
return label
}
return label + " · " + clip(s, 48)
}
func approvalIcon(outcome string) string {
if outcome == "REJECTED" {
return "✕"
}
return "⌘"
}
// approvalActionText renders the inline approval row, noting an auto-approval that fired via a
// standing grant (reason "grant:<id>").
func approvalActionText(outcome, tool, reason string) string {
verb := "approved"
switch outcome {
case "REJECTED":
verb = "rejected"
case "AUTO_APPROVED":
verb = "auto-approved"
}
txt := verb
if tool != "" {
txt = verb + " " + tool
}
if strings.HasPrefix(reason, "grant:") {
txt += " · via grant"
}
return txt
}
// onSessionAnnounced fills in a session's workflow identity (the announce is the
// only event carrying workflowId) and applies auto-focus. The session entry itself
// was already created by the auto-vivify path in applyServer.
@@ -414,7 +522,7 @@ func (m *Model) onApprovalRequired(msg protocol.ServerMessage) {
Rationale: rationale,
}
if s := m.session(msg.SessionID); s != nil {
s.Pending = info
s.enqueueApproval(info)
}
}
@@ -456,25 +564,25 @@ func (m *Model) onWorkflowProposed(msg protocol.ServerMessage) {
}
func (m *Model) onSnapshot(msg protocol.ServerMessage) {
var pending *Approval
if len(msg.PendingAppr) > 0 {
a := msg.PendingAppr[0]
pending = &Approval{
var queue []*Approval
for _, a := range msg.PendingAppr {
queue = append(queue, &Approval{
RequestID: a.RequestID, SessionID: msg.SessionID, Tier: a.Tier,
Risk: "unknown", ToolName: derefp(a.ToolName), Preview: derefp(a.Preview),
}
})
}
status := "running"
if msg.State != nil {
status = msg.State.Status
}
if pending != nil {
if len(queue) > 0 {
status = "PAUSED awaiting approval"
}
sess := Session{
ID: msg.SessionID, Status: status, WorkflowID: msg.WorkflowID,
Name: msg.WorkflowID, LastEventAt: nowMillis(), Pending: pending,
Name: msg.WorkflowID, LastEventAt: nowMillis(), PendingQueue: queue,
}
sess.syncPending()
if msg.State != nil && msg.State.CurrentStageID != nil {
sess.CurrentStage = *msg.State.CurrentStageID
}
@@ -0,0 +1,274 @@
package app
import (
"encoding/json"
"io"
"net/http"
"strings"
"time"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
)
// SessionSummary is one recent session from GET /sessions. Mirrors the server's
// SessionSummaryResponse (apps/server SessionRoutes.kt) — the wire shape.
type SessionSummary struct {
SessionID string `json:"sessionId"`
Status string `json:"status"`
Intent string `json:"intent"`
WorkflowID string `json:"workflowId"`
StageCount int `json:"stageCount"`
CreatedAt string `json:"createdAt"`
LastActivityAt string `json:"lastActivityAt"`
}
// sessionsLoadedMsg carries the result of a GET /sessions fetch. Exactly one of
// sessions / err is meaningful; err non-empty means the fetch failed and the
// overlay shows it instead of crashing.
type sessionsLoadedMsg struct {
sessions []SessionSummary
err string
}
// sessionResumeMsg is the outcome of asking the server to resume a session
// (POST /sessions/{id}/resume). On success its events replay onto the live
// stream the app already drains; on failure the overlay shows the error.
type sessionResumeMsg struct {
sessionID string
err string
}
// sessionsHTTPTimeout bounds the REST calls so a wedged server can't hang the UI.
const sessionsHTTPTimeout = 5 * time.Second
// fetchSessions is a tea.Cmd that GETs the recent-session roster over HTTP and
// reports it as a sessionsLoadedMsg. base is the http://host:port origin (from
// the ws client); an empty base yields an error message rather than a panic.
func fetchSessions(base string) tea.Cmd {
return func() tea.Msg {
if base == "" {
return sessionsLoadedMsg{err: "no server address"}
}
client := &http.Client{Timeout: sessionsHTTPTimeout}
resp, err := client.Get(base + "/sessions")
if err != nil {
return sessionsLoadedMsg{err: "fetch failed: " + err.Error()}
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return sessionsLoadedMsg{err: "server returned " + resp.Status}
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return sessionsLoadedMsg{err: "read failed: " + err.Error()}
}
var out []SessionSummary
if err := json.Unmarshal(body, &out); err != nil {
return sessionsLoadedMsg{err: "decode failed: " + err.Error()}
}
return sessionsLoadedMsg{sessions: out}
}
}
// resumeSession is a tea.Cmd that asks the server to rehydrate and relaunch a
// session (POST /sessions/{id}/resume). The server replays the rehydrated
// session onto the global stream this client already reads, so no WS re-dial is
// needed — the existing transport carries it.
func resumeSession(base, id string) tea.Cmd {
return func() tea.Msg {
if base == "" {
return sessionResumeMsg{sessionID: id, err: "no server address"}
}
client := &http.Client{Timeout: sessionsHTTPTimeout}
resp, err := client.Post(base+"/sessions/"+id+"/resume", "application/json", nil)
if err != nil {
return sessionResumeMsg{sessionID: id, err: "resume failed: " + err.Error()}
}
defer resp.Body.Close()
// 202 Accepted on success; 200 (already running) is fine too.
if resp.StatusCode != http.StatusAccepted && resp.StatusCode != http.StatusOK {
return sessionResumeMsg{sessionID: id, err: "server returned " + resp.Status}
}
return sessionResumeMsg{sessionID: id}
}
}
// openSessions opens the recent-session browser and kicks off the HTTP fetch.
// It is global (not bound to the selected session), so it opens from anywhere —
// including the idle list after a fresh reconnect with no live sessions yet.
func (m *Model) openSessions() tea.Cmd {
m.overlay = OverlaySessions
m.sessionListIndex = 0
m.sessionListErr = ""
m.sessionListLoading = true
return fetchSessions(m.client.HTTPBase())
}
// handleSessionsKey owns every key while the session browser is open: navigate
// the roster, esc closes, enter resumes the selected session.
func (m Model) handleSessionsKey(k keyMsg) (tea.Model, tea.Cmd) {
switch {
case k.Type == keyEsc || runeIs(k, "R"):
m.overlay = OverlayNone
case k.Type == keyUp || runeIs(k, "k"):
if m.sessionListIndex > 0 {
m.sessionListIndex--
}
case k.Type == keyDown || runeIs(k, "j"):
if m.sessionListIndex < len(m.sessionList)-1 {
m.sessionListIndex++
}
case k.Type == keyEnter:
if m.sessionListIndex >= 0 && m.sessionListIndex < len(m.sessionList) {
return m.openSelectedSession()
}
}
return m, nil
}
// openSelectedSession focuses the highlighted recent session locally and asks the
// server to resume it. Focusing it first means its replayed events land on the
// session the operator is now looking at; the resume cmd does the rest over the
// existing stream (no WS re-dial — see resumeSession).
func (m Model) openSelectedSession() (tea.Model, tea.Cmd) {
sum := m.sessionList[m.sessionListIndex]
s := m.ensureSession(sum.SessionID)
if sum.WorkflowID != "" {
s.WorkflowID = sum.WorkflowID
if s.Name == "" {
s.Name = sum.WorkflowID
}
}
if sum.Status != "" {
s.Status = sum.Status
}
m.selectedID = sum.SessionID
m.sessionEntered = true
m.overlay = OverlayNone
return m, resumeSession(m.client.HTTPBase(), sum.SessionID)
}
func (m Model) sessionsModal() string {
t := m.theme
w := m.modalWidth()
var b strings.Builder
b.WriteString(m.titleLine("sessions"))
if len(m.sessionList) > 0 {
b.WriteString(mbg(t, " ("+itoa(m.sessionListIndex+1)+"/"+itoa(len(m.sessionList))+")", t.P.Faint))
}
b.WriteString("\n\n")
if m.sessionListErr != "" {
b.WriteString(lipgloss.NewStyle().Foreground(t.P.Bad).Background(t.P.BgPanel).Render(" ▲ "+truncate(m.sessionListErr, w-8)) + "\n")
b.WriteString("\n" + modalHints(t, [][2]string{{"R/esc", "close"}}))
return t.Overlay.Width(w).Render(b.String())
}
if m.sessionListLoading && len(m.sessionList) == 0 {
b.WriteString(mbg(t, " loading…", t.P.Faint) + "\n")
b.WriteString("\n" + modalHints(t, [][2]string{{"R/esc", "close"}}))
return t.Overlay.Width(w).Render(b.String())
}
if len(m.sessionList) == 0 {
b.WriteString(mbg(t, " no recent sessions", t.P.Faint) + "\n")
b.WriteString("\n" + modalHints(t, [][2]string{{"R/esc", "close"}}))
return t.Overlay.Width(w).Render(b.String())
}
// Windowed roster, keeping the selected row in view.
bodyH := m.height*70/100 - 6
if bodyH < 4 {
bodyH = 4
}
off := 0
if len(m.sessionList) > bodyH {
off = m.sessionListIndex - bodyH/2
if off < 0 {
off = 0
}
if off > len(m.sessionList)-bodyH {
off = len(m.sessionList) - bodyH
}
}
end := off + bodyH
if end > len(m.sessionList) {
end = len(m.sessionList)
}
for i := off; i < end; i++ {
b.WriteString(m.sessionListRow(i) + "\n")
}
b.WriteString("\n" + modalHints(t, [][2]string{
{"↑↓", "select"}, {"enter", "resume"}, {"R/esc", "close"},
}))
return t.Overlay.Width(w).Render(b.String())
}
// sessionListRow renders one roster line: marker, short id, status, current
// stage (if any), and relative age of the last activity.
func (m Model) sessionListRow(i int) string {
t := m.theme
s := m.sessionList[i]
marker := mbg(t, " ", t.P.BgPanel)
idFg := t.P.Fg
if i == m.sessionListIndex {
marker = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▸ ")
idFg = t.P.FgStrong
}
idCell := lipgloss.NewStyle().Foreground(idFg).Background(t.P.BgPanel).Render(padRaw(shortSessionID(s.SessionID), 8))
statusCell := lipgloss.NewStyle().Foreground(statusColor(t, s.Status)).Background(t.P.BgPanel).Bold(true).
Render(padRaw(statusLabel(s.Status), 9))
stage := s.WorkflowID
if s.StageCount > 0 {
stage += " ·" + itoa(s.StageCount) + "stg"
}
stageCell := mbg(t, padRaw(stage, 22), t.P.Accent2)
dateCell := mbg(t, padRaw(absDateISO(s.LastActivityAt), 12), t.P.Dim)
ageCell := mbg(t, "· "+relativeAge(s.LastActivityAt), t.P.Faint)
return marker + idCell + " " + statusCell + " " + stageCell + " " + dateCell + " " + ageCell
}
// absDateISO renders an ISO-8601 instant (the server's LastActivityAt) as a compact local
// date via shortDateTime, or "—" when absent/unparseable.
func absDateISO(iso string) string {
if iso == "" {
return "—"
}
ts, err := time.Parse(time.RFC3339Nano, iso)
if err != nil {
if ts, err = time.Parse(time.RFC3339, iso); err != nil {
return "—"
}
}
return shortDateTime(ts.UnixMilli())
}
// shortSessionID trims a long session id to a recognizable prefix for the list.
func shortSessionID(id string) string {
if len(id) <= 8 {
return id
}
return id[:8]
}
// relativeAge renders an ISO-8601 instant string (Instant.toString() on the
// server) as a compact "Ns ago" age, falling back to "—" when absent/unparseable.
func relativeAge(iso string) string {
if iso == "" {
return "—"
}
ts, err := time.Parse(time.RFC3339Nano, iso)
if err != nil {
ts, err = time.Parse(time.RFC3339, iso)
if err != nil {
return "—"
}
}
return agoLabel(nowMillis() - ts.UnixMilli())
}
@@ -0,0 +1,150 @@
package app
import (
"strings"
"testing"
)
func sessionsModel() Model {
m := NewModel(nil)
m.width, m.height = 120, 40
m.theme = NewTheme(SoftBlue)
return m
}
// sampleSummaries is a deterministic two-row roster used across the tests.
func sampleSummaries() []SessionSummary {
return []SessionSummary{
{SessionID: "aaaaaaaa-1111", Status: "ACTIVE", WorkflowID: "refactor", StageCount: 2},
{SessionID: "bbbbbbbb-2222", Status: "FAILED", WorkflowID: "healthcheck"},
}
}
func TestSessionsOverlayToggle(t *testing.T) {
m := sessionsModel()
// `R` opens the browser from the idle list.
updated, _ := m.handleNormalKey(keyMsg{Type: keyRunes, Runes: []rune("R")})
m = updated.(Model)
if m.overlay != OverlaySessions {
t.Fatalf("expected OverlaySessions after R, got %d", m.overlay)
}
if !m.sessionListLoading {
t.Fatalf("expected loading state right after open")
}
// `esc` closes it (routed through the overlay key handler).
updated, _ = m.handleOverlayKey(keyMsg{Type: keyEsc})
m = updated.(Model)
if m.overlay != OverlayNone {
t.Fatalf("expected overlay closed after esc, got %d", m.overlay)
}
}
func TestSessionsLoadedPopulatesAndRenders(t *testing.T) {
m := sessionsModel()
m.overlay = OverlaySessions
m.sessionListLoading = true
m.applySessionsLoaded(sessionsLoadedMsg{sessions: sampleSummaries()})
if m.sessionListLoading {
t.Fatalf("expected loading cleared after load")
}
if len(m.sessionList) != 2 {
t.Fatalf("expected 2 sessions, got %d", len(m.sessionList))
}
out := m.sessionsModal()
if !strings.Contains(out, "aaaaaaaa") || !strings.Contains(out, "bbbbbbbb") {
t.Fatalf("modal missing session ids:\n%s", out)
}
if !strings.Contains(out, "ACTIVE") || !strings.Contains(out, "FAILED") {
t.Fatalf("modal missing statuses:\n%s", out)
}
}
func TestSessionsNavigationBoundsChecked(t *testing.T) {
m := sessionsModel()
m.overlay = OverlaySessions
m.sessionList = sampleSummaries()
// Up at the top is a no-op (clamped at 0).
updated, _ := m.handleSessionsKey(keyMsg{Type: keyUp})
m = updated.(Model)
if m.sessionListIndex != 0 {
t.Fatalf("expected index clamped to 0 at top, got %d", m.sessionListIndex)
}
// Down moves to row 1.
updated, _ = m.handleSessionsKey(keyMsg{Type: keyDown})
m = updated.(Model)
if m.sessionListIndex != 1 {
t.Fatalf("expected index 1 after down, got %d", m.sessionListIndex)
}
// Down again is clamped at the last row.
updated, _ = m.handleSessionsKey(keyMsg{Type: keyDown})
m = updated.(Model)
if m.sessionListIndex != 1 {
t.Fatalf("expected index clamped to 1 at bottom, got %d", m.sessionListIndex)
}
// `j` is the vim-style alias for down (still clamped here).
updated, _ = m.handleSessionsKey(keyMsg{Type: keyRunes, Runes: []rune("j")})
m = updated.(Model)
if m.sessionListIndex != 1 {
t.Fatalf("expected j clamped at bottom, got %d", m.sessionListIndex)
}
}
func TestSessionsEnterFocusesAndEmitsResume(t *testing.T) {
m := sessionsModel()
m.overlay = OverlaySessions
m.sessionList = sampleSummaries()
m.sessionListIndex = 1 // the FAILED healthcheck row
updated, cmd := m.handleSessionsKey(keyMsg{Type: keyEnter})
m = updated.(Model)
if m.overlay != OverlayNone {
t.Fatalf("expected overlay closed after resume, got %d", m.overlay)
}
if m.selectedID != "bbbbbbbb-2222" {
t.Fatalf("expected selected id bbbbbbbb-2222, got %q", m.selectedID)
}
if !m.sessionEntered {
t.Fatalf("expected session entered after resume")
}
if s := m.session("bbbbbbbb-2222"); s == nil || s.WorkflowID != "healthcheck" {
t.Fatalf("expected vivified session with workflow healthcheck, got %+v", s)
}
// A resume cmd is emitted; with a nil client (no server addr) it resolves to a
// sessionResumeMsg carrying the error — exercised here so it can't panic.
if cmd == nil {
t.Fatalf("expected a resume cmd to be emitted")
}
if msg, ok := cmd().(sessionResumeMsg); !ok {
t.Fatalf("expected sessionResumeMsg from resume cmd, got %T", cmd())
} else if msg.sessionID != "bbbbbbbb-2222" {
t.Fatalf("resume msg for wrong session: %q", msg.sessionID)
}
}
func TestSessionsFetchErrorShownNoPanic(t *testing.T) {
m := sessionsModel()
m.overlay = OverlaySessions
m.sessionListLoading = true
m.applySessionsLoaded(sessionsLoadedMsg{err: "server returned 503 Service Unavailable"})
if m.sessionListLoading {
t.Fatalf("expected loading cleared on error")
}
if m.sessionListErr == "" {
t.Fatalf("expected error recorded")
}
out := m.sessionsModal()
if !strings.Contains(out, "503") {
t.Fatalf("modal should surface the fetch error:\n%s", out)
}
}
func TestRelativeAgeParsing(t *testing.T) {
if got := relativeAge(""); got != "—" {
t.Fatalf("empty age should be em-dash, got %q", got)
}
if got := relativeAge("not-a-timestamp"); got != "—" {
t.Fatalf("unparseable age should be em-dash, got %q", got)
}
if got := relativeAge("2020-01-01T00:00:00Z"); !strings.HasSuffix(got, "ago") {
t.Fatalf("a real instant should render an age, got %q", got)
}
}
+43
View File
@@ -0,0 +1,43 @@
package app
import (
"testing"
)
// `/` as the first char of a chat line opens the command palette inline (opencode-style),
// without typing a literal slash.
func TestSlashOpensPaletteOnEmptyLine(t *testing.T) {
m := NewModel(nil)
m.editMode = ModeInsert
m.inputMode = ModeRouter
m.inputBuffer = ""
next, _ := m.handleInsertKey(keyMsg{Type: keyRunes, Runes: []rune("/")})
nm := next.(Model)
if nm.overlay != OverlayPalette {
t.Fatalf("'/' on an empty line should open the palette; overlay=%d", nm.overlay)
}
if nm.inputBuffer != "" {
t.Fatalf("'/' should not be typed literally; buffer=%q", nm.inputBuffer)
}
}
// Mid-line `/` is a literal slash (e.g. a path), not a command trigger.
func TestSlashLiteralMidLine(t *testing.T) {
m := NewModel(nil)
m.editMode = ModeInsert
m.inputMode = ModeRouter
m.inputBuffer = "path"
m.inputCursor = len("path")
next, _ := m.handleInsertKey(keyMsg{Type: keyRunes, Runes: []rune("/")})
nm := next.(Model)
if nm.overlay != OverlayNone {
t.Fatalf("mid-line '/' must stay literal, not open the palette; overlay=%d", nm.overlay)
}
if nm.inputBuffer != "path/" {
t.Fatalf("mid-line '/' should be typed; buffer=%q", nm.inputBuffer)
}
}
@@ -0,0 +1,57 @@
package app
import (
"strings"
"testing"
)
// Toggling a segment hides it from the rendered status bar; toggling again restores it.
// Reuses inSessionModel (adaptive_layout_test.go): stage "write_script", a long model id.
func TestStatusBarToggleHidesSegment(t *testing.T) {
t.Setenv("CORREX_CONFIG_HOME", t.TempDir()) // isolate persistence
m := inSessionModel(220, 30)
m.sbHidden = map[string]bool{} // start from a clean prefs state
if !strings.Contains(m.renderStatus(), "write_script") {
t.Fatal("stage should be visible by default")
}
m.toggleStatusSegment("stage")
if strings.Contains(m.renderStatus(), "write_script") {
t.Fatal("stage should be hidden after toggle")
}
if !strings.Contains(m.renderStatus(), "llama-cpp") {
t.Fatal("model should still be visible (only stage was hidden)")
}
m.toggleStatusSegment("stage")
if !strings.Contains(m.renderStatus(), "write_script") {
t.Fatal("stage should be visible again after second toggle")
}
}
func TestStatusBarModelHide(t *testing.T) {
t.Setenv("CORREX_CONFIG_HOME", t.TempDir())
m := inSessionModel(220, 30)
m.sbHidden = map[string]bool{}
m.toggleStatusSegment("model")
if strings.Contains(m.renderStatus(), "llama-cpp") {
t.Fatal("model should be hidden after toggle")
}
}
// A toggle persists to tui-prefs.json and is reloaded on the next launch.
func TestStatusBarPrefsPersist(t *testing.T) {
t.Setenv("CORREX_CONFIG_HOME", t.TempDir())
m := inSessionModel(220, 30)
m.sbHidden = map[string]bool{}
m.toggleStatusSegment("gauge")
m.toggleStatusSegment("clock")
reloaded := loadStatusbarHidden()
if !reloaded["gauge"] || !reloaded["clock"] {
t.Fatalf("persisted hidden set = %v, want gauge+clock hidden", reloaded)
}
if reloaded["stage"] {
t.Fatal("stage was never toggled; should not be persisted as hidden")
}
}
+493
View File
@@ -0,0 +1,493 @@
package app
import (
"encoding/json"
"image/color"
"io"
"net/http"
"strings"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
)
// TaskSummary is one task from GET /tasks. Mirrors the server's TaskResponse
// (apps/server routes/TaskRoutes.kt) — the wire shape. Nullable server fields
// decode to the zero value ("" / nil) here.
type TaskSummary struct {
ID string `json:"id"`
Key string `json:"key"`
Status string `json:"status"`
Title string `json:"title"`
Goal string `json:"goal"`
AcceptanceCriteria []string `json:"acceptanceCriteria"`
AffectedPaths []string `json:"affectedPaths"`
Claimant string `json:"claimant"`
Links []TaskLinkWire `json:"links"`
Notes []TaskNoteWire `json:"notes"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
// Dependency-graph status from the server: Ready = workable now (TODO, no unmet deps);
// BlockedBy = ids of unfinished tasks this one waits on. Absent on older servers → zero values.
Ready bool `json:"ready"`
BlockedBy []string `json:"blockedBy"`
}
type TaskLinkWire struct {
TargetID string `json:"targetId"`
Type string `json:"type"`
TargetKind string `json:"targetKind"`
}
type TaskNoteWire struct {
Author string `json:"author"`
Body string `json:"body"`
At string `json:"at"`
}
// tasksLoadedMsg carries the result of a GET /tasks fetch. Exactly one of tasks /
// err is meaningful; a non-empty err means the fetch failed and the board shows it.
type tasksLoadedMsg struct {
tasks []TaskSummary
err string
}
// fetchTasks is a tea.Cmd that GETs the cross-project task board over HTTP and
// reports it as a tasksLoadedMsg. base is the http://host:port origin (from the ws
// client); an empty base yields an error message rather than a panic.
func fetchTasks(base string) tea.Cmd {
return func() tea.Msg {
if base == "" {
return tasksLoadedMsg{err: "no server address"}
}
client := &http.Client{Timeout: sessionsHTTPTimeout}
resp, err := client.Get(base + "/tasks")
if err != nil {
return tasksLoadedMsg{err: "fetch failed: " + err.Error()}
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return tasksLoadedMsg{err: "server returned " + resp.Status}
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return tasksLoadedMsg{err: "read failed: " + err.Error()}
}
var out []TaskSummary
if err := json.Unmarshal(body, &out); err != nil {
return tasksLoadedMsg{err: "decode failed: " + err.Error()}
}
return tasksLoadedMsg{tasks: out}
}
}
// openTasks opens the cross-project task board and kicks off the HTTP fetch. It is
// global (not bound to a session), so it opens from anywhere.
func (m *Model) openTasks() tea.Cmd {
m.overlay = OverlayTasks
m.taskListIndex = 0
m.taskListErr = ""
m.taskDetail = false
m.taskDetailScroll = 0
m.taskFilter = ""
m.taskFilterTyping = false
m.taskListLoading = true
return fetchTasks(m.client.HTTPBase())
}
// filteredTasks narrows the board by the `/` query — a case-insensitive substring over
// id/title/goal. An empty query returns the whole list.
func (m Model) filteredTasks() []TaskSummary {
if strings.TrimSpace(m.taskFilter) == "" {
return m.taskList
}
q := strings.ToLower(m.taskFilter)
var out []TaskSummary
for _, tk := range m.taskList {
if strings.Contains(strings.ToLower(tk.ID+" "+tk.Title+" "+tk.Goal), q) {
out = append(out, tk)
}
}
return out
}
// applyTasksLoaded folds a GET /tasks result into the board, clamping the cursor and
// surfacing any fetch error in place of a crash.
func (m *Model) applyTasksLoaded(msg tasksLoadedMsg) {
m.taskListLoading = false
if msg.err != "" {
m.taskListErr = msg.err
return
}
m.taskListErr = ""
m.taskList = msg.tasks
if m.taskListIndex >= len(m.taskList) {
m.taskListIndex = 0
}
}
// selectedTask returns the highlighted task, or false when the list is empty / the
// cursor is out of range.
func (m Model) selectedTask() (TaskSummary, bool) {
f := m.filteredTasks()
if m.taskListIndex < 0 || m.taskListIndex >= len(f) {
return TaskSummary{}, false
}
return f[m.taskListIndex], true
}
// handleTasksKey owns every key while the board is open. In list mode: navigate, r
// refreshes, enter opens the detail pane, T/esc closes. In detail mode: scroll, and
// enter/esc returns to the list.
func (m Model) handleTasksKey(k keyMsg) (tea.Model, tea.Cmd) {
if m.taskDetail {
switch {
case k.Type == keyEsc || k.Type == keyEnter:
m.taskDetail = false
case k.Type == keyUp || runeIs(k, "k"):
m.scrollTaskDetail(-1)
case k.Type == keyDown || runeIs(k, "j"):
m.scrollTaskDetail(1)
case k.Type == keyPgUp:
m.scrollTaskDetail(-m.taskDetailBodyH())
case k.Type == keyPgDown:
m.scrollTaskDetail(m.taskDetailBodyH())
case runeIs(k, "g"):
m.taskDetailScroll = 0
case runeIs(k, "G"):
m.taskDetailScroll = m.taskDetailMaxScroll()
}
return m, nil
}
// While editing the `/` filter, keys feed the query (esc/enter stop editing, keeping it).
if m.taskFilterTyping {
switch {
case k.Type == keyEnter || k.Type == keyEsc:
m.taskFilterTyping = false
case k.Type == keyBackspace:
if n := len(m.taskFilter); n > 0 {
m.taskFilter = m.taskFilter[:n-1]
m.taskListIndex = 0
}
case k.Type == keyRunes || k.Type == keySpace:
m.taskFilter += string(k.Runes)
m.taskListIndex = 0
}
return m, nil
}
switch {
case k.Type == keyEsc || runeIs(k, "T"):
m.overlay = OverlayNone
case runeIs(k, "/"):
m.taskFilterTyping = true
case runeIs(k, "c"):
m.taskFilter = ""
m.taskListIndex = 0
case k.Type == keyUp || runeIs(k, "k"):
if m.taskListIndex > 0 {
m.taskListIndex--
}
case k.Type == keyDown || runeIs(k, "j"):
if m.taskListIndex < len(m.filteredTasks())-1 {
m.taskListIndex++
}
case runeIs(k, "r"):
return m, m.openTasks()
case k.Type == keyEnter:
if _, ok := m.selectedTask(); ok {
m.taskDetail = true
m.taskDetailScroll = 0
}
}
return m, nil
}
// taskDetailBodyH is the number of detail lines visible in the detail pane.
func (m Model) taskDetailBodyH() int {
h := m.height*70/100 - 6
if h < 4 {
h = 4
}
return h
}
// taskDetailMaxScroll keeps the last page of detail anchored to the body bottom.
func (m Model) taskDetailMaxScroll() int {
max := len(m.taskDetailBody(m.modalWidth()-4)) - m.taskDetailBodyH()
if max < 0 {
max = 0
}
return max
}
// scrollTaskDetail moves the detail offset by delta lines, clamped to [0, max].
func (m *Model) scrollTaskDetail(delta int) {
max := m.taskDetailMaxScroll()
off := m.taskDetailScroll + delta
if off > max {
off = max
}
if off < 0 {
off = 0
}
m.taskDetailScroll = off
}
func (m Model) tasksModal() string {
if m.taskDetail {
return m.taskDetailModal()
}
t := m.theme
w := m.modalWidth()
tasks := m.filteredTasks()
var b strings.Builder
b.WriteString(m.titleLine("tasks"))
if len(tasks) > 0 {
b.WriteString(mbg(t, " ("+itoa(m.taskListIndex+1)+"/"+itoa(len(tasks))+")", t.P.Faint))
}
b.WriteString("\n\n")
if m.taskListErr != "" {
b.WriteString(lipgloss.NewStyle().Foreground(t.P.Bad).Background(t.P.BgPanel).Render(" ▲ "+truncate(m.taskListErr, w-8)) + "\n")
b.WriteString("\n" + modalHints(t, [][2]string{{"T/esc", "close"}}))
return t.Overlay.Width(w).Render(b.String())
}
if m.taskListLoading && len(m.taskList) == 0 {
b.WriteString(mbg(t, " loading…", t.P.Faint) + "\n")
b.WriteString("\n" + modalHints(t, [][2]string{{"T/esc", "close"}}))
return t.Overlay.Width(w).Render(b.String())
}
// Filter prompt: shown while editing the `/` query or whenever one is set.
if m.taskFilterTyping || m.taskFilter != "" {
b.WriteString(m.taskFilterLine() + "\n\n")
}
if len(tasks) == 0 {
msg := " no tasks"
if m.taskFilter != "" {
msg = " no tasks match \"" + m.taskFilter + "\""
}
b.WriteString(mbg(t, msg, t.P.Faint) + "\n")
b.WriteString("\n" + modalHints(t, m.taskListHints()))
return t.Overlay.Width(w).Render(b.String())
}
// Windowed roster, keeping the selected row in view.
bodyH := m.height*70/100 - 6
if bodyH < 4 {
bodyH = 4
}
off := 0
if len(tasks) > bodyH {
off = m.taskListIndex - bodyH/2
if off < 0 {
off = 0
}
if off > len(tasks)-bodyH {
off = len(tasks) - bodyH
}
}
end := off + bodyH
if end > len(tasks) {
end = len(tasks)
}
for i := off; i < end; i++ {
b.WriteString(m.taskListRow(tasks, i) + "\n")
}
b.WriteString("\n" + modalHints(t, m.taskListHints()))
return t.Overlay.Width(w).Render(b.String())
}
// taskListHints is the list-mode footer (shared by the populated and empty-filter views).
func (m Model) taskListHints() [][2]string {
return [][2]string{{"↑↓", "select"}, {"enter", "detail"}, {"/", "filter"}, {"r", "refresh"}, {"T/esc", "close"}}
}
// taskFilterLine renders the `/` filter prompt over the current query, with a caret while editing.
func (m Model) taskFilterLine() string {
t := m.theme
prompt := lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("/ ")
caret := mbg(t, "", t.P.BgPanel)
if m.taskFilterTyping && m.caretVisible() {
caret = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▏")
}
if m.taskFilter == "" {
return prompt + caret + mbg(t, "type to filter tasks…", t.P.Faint)
}
return prompt + mbg(t, m.taskFilter, t.P.FgStrong) + caret
}
// taskListRow renders one board line: marker, id, status, title, and claimant.
func (m Model) taskListRow(tasks []TaskSummary, i int) string {
t := m.theme
tk := tasks[i]
marker := mbg(t, " ", t.P.BgPanel)
idFg := t.P.Fg
if i == m.taskListIndex {
marker = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▸ ")
idFg = t.P.FgStrong
}
idCell := lipgloss.NewStyle().Foreground(idFg).Background(t.P.BgPanel).Render(padRaw(tk.ID, 12))
statusCell := lipgloss.NewStyle().Foreground(taskStatusColor(t, tk.Status)).Background(t.P.BgPanel).Bold(true).
Render(padRaw(tk.Status, 12))
claim := ""
if tk.Claimant != "" {
claim = " @" + shortSessionID(tk.Claimant)
}
readyCell := taskReadyCell(t, tk) // 2-col dependency-readiness glyph
titleW := m.modalWidth() - 6 - 12 - 1 - 12 - 1 - 2 - 1 - len([]rune(claim))
if titleW < 8 {
titleW = 8
}
titleCell := mbg(t, padRaw(truncate(tk.Title, titleW), titleW), t.P.Fg)
claimCell := mbg(t, claim, t.P.Faint)
return marker + idCell + " " + statusCell + " " + readyCell + " " + titleCell + claimCell
}
// taskReadyCell is a 2-column glyph showing dependency-graph readiness, so a decomposed graph reads
// at a glance: a filled dot when the task is workable now (TODO, no unmet deps), a hollow dot when it
// is waiting on unfinished blockers, blank for anything already in flight or finished.
func taskReadyCell(t Theme, tk TaskSummary) string {
switch {
case tk.Ready:
return lipgloss.NewStyle().Foreground(t.P.OK).Background(t.P.BgPanel).Render(padRaw("●", 2))
case len(tk.BlockedBy) > 0:
return lipgloss.NewStyle().Foreground(t.P.Dim).Background(t.P.BgPanel).Render(padRaw("○", 2))
default:
return mbg(t, " ", t.P.BgPanel)
}
}
func (m Model) taskDetailModal() string {
t := m.theme
w := m.modalWidth()
tk, _ := m.selectedTask()
body := m.taskDetailBody(w - 4)
bodyH := m.taskDetailBodyH()
off := m.taskDetailScroll
if mx := m.taskDetailMaxScroll(); off > mx {
off = mx
}
if off < 0 {
off = 0
}
var b strings.Builder
b.WriteString(m.titleLine("task " + tk.ID))
b.WriteString(lipgloss.NewStyle().Foreground(taskStatusColor(t, tk.Status)).Background(t.P.BgPanel).Bold(true).
Render(" " + tk.Status))
if len(body) > bodyH {
b.WriteString(mbg(t, " ("+itoa(off+1)+"-"+itoa(min(off+bodyH, len(body)))+"/"+itoa(len(body))+")", t.P.Faint))
}
b.WriteString("\n\n")
end := off + bodyH
if end > len(body) {
end = len(body)
}
for i := off; i < end; i++ {
b.WriteString(body[i] + "\n")
}
b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "scroll"}, {"g/G", "ends"}, {"enter/esc", "back"}}))
return t.Overlay.Width(w).Render(b.String())
}
// taskDetailBody renders the selected task as styled, wrapped lines: goal, acceptance
// criteria, affected paths, links, and notes. Built entirely from the list payload, so
// it needs no extra fetch.
func (m Model) taskDetailBody(width int) []string {
t := m.theme
tk, ok := m.selectedTask()
if !ok {
return []string{mbg(t, " (no task selected)", t.P.Faint)}
}
if width < 8 {
width = 8
}
var out []string
section := func(label string) {
out = append(out, lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.BgPanel).Bold(true).Render(label))
}
put := func(s string, fg color.Color) {
for _, ln := range wrapLines([]string{s}, width) {
out = append(out, mbg(t, ln, fg))
}
}
put(tk.Title, t.P.FgStrong)
if tk.Claimant != "" {
out = append(out, mbg(t, "claimant: "+tk.Claimant, t.P.Faint))
}
if tk.Ready {
out = append(out, mbg(t, "● ready to work (no unmet dependencies)", t.P.OK))
} else if len(tk.BlockedBy) > 0 {
out = append(out, mbg(t, "○ blocked — waiting on "+strings.Join(tk.BlockedBy, ", "), t.P.Dim))
}
out = append(out, "")
if tk.Goal != "" {
section("goal")
put(tk.Goal, t.P.Fg)
out = append(out, "")
}
if len(tk.AcceptanceCriteria) > 0 {
section("acceptance criteria")
for _, c := range tk.AcceptanceCriteria {
put("- "+c, t.P.Fg)
}
out = append(out, "")
}
if len(tk.AffectedPaths) > 0 {
section("affected paths")
for _, p := range tk.AffectedPaths {
out = append(out, mbg(t, clip(" "+p, width), t.P.Dim))
}
out = append(out, "")
}
if len(tk.Links) > 0 {
section("links")
for _, l := range tk.Links {
out = append(out, mbg(t, clip(" "+l.TargetID+" ("+l.Type+" → "+l.TargetKind+")", width), t.P.Fg))
}
out = append(out, "")
}
if len(tk.Notes) > 0 {
section("notes")
for _, n := range tk.Notes {
put("["+n.Author+"] "+n.Body, t.P.Fg)
}
out = append(out, "")
}
// Drop the trailing blank so the body never has a dangling empty line.
for len(out) > 0 && out[len(out)-1] == "" {
out = out[:len(out)-1]
}
return out
}
// taskStatusColor maps a task status to a palette color for the board / detail header.
func taskStatusColor(t Theme, status string) color.Color {
switch status {
case "DONE":
return t.P.OK
case "IN_PROGRESS":
return t.P.Accent
case "IN_REVIEW":
return t.P.Accent2
case "BLOCKED":
return t.P.Bad
case "CANCELLED":
return t.P.Dim
default: // TODO
return t.P.Faint
}
}
@@ -0,0 +1,186 @@
package app
import (
"strings"
"testing"
)
func tasksModel() Model {
m := NewModel(nil)
m.width, m.height = 120, 40
m.theme = NewTheme(SoftBlue)
return m
}
// sampleTasks is a deterministic two-row board used across the tests.
func sampleTasks() []TaskSummary {
return []TaskSummary{
{
ID: "auth-1", Key: "auth-1", Status: "IN_PROGRESS", Title: "JWT refresh",
Goal: "users stay authenticated", AcceptanceCriteria: []string{"rotates"},
Claimant: "aaaaaaaa-1111",
Links: []TaskLinkWire{{TargetID: "adr-7", Type: "IMPLEMENTS", TargetKind: "DOC"}},
Notes: []TaskNoteWire{{Author: "AGENT", Body: "kickoff"}},
},
{ID: "billing-3", Key: "billing-3", Status: "DONE", Title: "Invoice export"},
}
}
func TestTasksOverlayToggle(t *testing.T) {
m := tasksModel()
// `T` opens the board from the idle list.
updated, _ := m.handleNormalKey(keyMsg{Type: keyRunes, Runes: []rune("T")})
m = updated.(Model)
if m.overlay != OverlayTasks {
t.Fatalf("expected OverlayTasks after T, got %d", m.overlay)
}
if !m.taskListLoading {
t.Fatalf("expected loading state right after open")
}
// `esc` closes it (routed through the overlay key handler).
updated, _ = m.handleOverlayKey(keyMsg{Type: keyEsc})
m = updated.(Model)
if m.overlay != OverlayNone {
t.Fatalf("expected overlay closed after esc, got %d", m.overlay)
}
}
func TestTasksLoadedPopulatesAndRenders(t *testing.T) {
m := tasksModel()
m.overlay = OverlayTasks
m.taskListLoading = true
m.applyTasksLoaded(tasksLoadedMsg{tasks: sampleTasks()})
if m.taskListLoading {
t.Fatalf("expected loading cleared after load")
}
if len(m.taskList) != 2 {
t.Fatalf("expected 2 tasks, got %d", len(m.taskList))
}
out := m.tasksModal()
if !strings.Contains(out, "auth-1") || !strings.Contains(out, "billing-3") {
t.Fatalf("modal missing task ids:\n%s", out)
}
if !strings.Contains(out, "IN_PROGRESS") || !strings.Contains(out, "DONE") {
t.Fatalf("modal missing statuses:\n%s", out)
}
}
func TestTasksEnterOpensDetailWithBundleFields(t *testing.T) {
m := tasksModel()
m.overlay = OverlayTasks
m.taskList = sampleTasks()
m.taskListIndex = 0
updated, _ := m.handleTasksKey(keyMsg{Type: keyEnter})
m = updated.(Model)
if !m.taskDetail {
t.Fatalf("expected detail pane open after enter")
}
out := m.tasksModal()
for _, want := range []string{"users stay authenticated", "rotates", "adr-7", "kickoff"} {
if !strings.Contains(out, want) {
t.Fatalf("detail missing %q:\n%s", want, out)
}
}
// esc returns to the list, not all the way out.
updated, _ = m.handleTasksKey(keyMsg{Type: keyEsc})
m = updated.(Model)
if m.taskDetail {
t.Fatalf("expected detail closed after esc")
}
if m.overlay != OverlayTasks {
t.Fatalf("expected to stay on the board after esc from detail, got %d", m.overlay)
}
}
func TestTasksNavigationBoundsChecked(t *testing.T) {
m := tasksModel()
m.overlay = OverlayTasks
m.taskList = sampleTasks()
// Up at the top is a no-op (clamped at 0).
updated, _ := m.handleTasksKey(keyMsg{Type: keyUp})
m = updated.(Model)
if m.taskListIndex != 0 {
t.Fatalf("expected index clamped to 0 at top, got %d", m.taskListIndex)
}
// Down then clamped at the last row.
updated, _ = m.handleTasksKey(keyMsg{Type: keyDown})
m = updated.(Model)
updated, _ = m.handleTasksKey(keyMsg{Type: keyDown})
m = updated.(Model)
if m.taskListIndex != 1 {
t.Fatalf("expected index clamped to 1 at bottom, got %d", m.taskListIndex)
}
}
func TestTasksFilterNarrowsList(t *testing.T) {
m := tasksModel()
m.overlay = OverlayTasks
m.taskList = sampleTasks()
// `/` enters filter typing; a query narrows the board to matching rows.
updated, _ := m.handleTasksKey(keyMsg{Type: keyRunes, Runes: []rune("/")})
m = updated.(Model)
if !m.taskFilterTyping {
t.Fatalf("expected filter typing after /")
}
for _, r := range "invoice" {
updated, _ = m.handleTasksKey(keyMsg{Type: keyRunes, Runes: []rune{r}})
m = updated.(Model)
}
if got := m.filteredTasks(); len(got) != 1 || got[0].ID != "billing-3" {
t.Fatalf("expected only billing-3 to match 'invoice', got %+v", got)
}
// enter stops editing but keeps the query; the selected task is the filtered one.
updated, _ = m.handleTasksKey(keyMsg{Type: keyEnter})
m = updated.(Model)
if m.taskFilterTyping {
t.Fatalf("expected typing to stop on enter")
}
if sel, ok := m.selectedTask(); !ok || sel.ID != "billing-3" {
t.Fatalf("expected selected task billing-3 under filter, got %+v ok=%v", sel, ok)
}
// `c` clears the filter back to the full board.
updated, _ = m.handleTasksKey(keyMsg{Type: keyRunes, Runes: []rune("c")})
m = updated.(Model)
if len(m.filteredTasks()) != 2 {
t.Fatalf("expected full board after clear, got %d", len(m.filteredTasks()))
}
}
func TestTasksReadyAndBlockedSurfaced(t *testing.T) {
m := tasksModel()
m.overlay = OverlayTasks
m.taskList = []TaskSummary{
{ID: "webui-2", Key: "webui-2", Status: "TODO", Title: "Scaffold app", Ready: true},
{ID: "webui-3", Key: "webui-3", Status: "TODO", Title: "Auth view", BlockedBy: []string{"webui-2"}},
}
// A ready task's detail says so; a blocked task's detail names what it waits on.
m.taskDetail = true
m.taskListIndex = 0
if out := m.tasksModal(); !strings.Contains(out, "ready to work") {
t.Fatalf("expected ready marker in detail:\n%s", out)
}
m.taskListIndex = 1
if out := m.tasksModal(); !strings.Contains(out, "waiting on webui-2") {
t.Fatalf("expected blocked-by detail naming the blocker:\n%s", out)
}
}
func TestTasksFetchErrorShownNoPanic(t *testing.T) {
m := tasksModel()
m.overlay = OverlayTasks
m.taskListLoading = true
m.applyTasksLoaded(tasksLoadedMsg{err: "server returned 503 Service Unavailable"})
if m.taskListLoading {
t.Fatalf("expected loading cleared on error")
}
if m.taskListErr == "" {
t.Fatalf("expected error recorded")
}
out := m.tasksModal()
if !strings.Contains(out, "503") {
t.Fatalf("modal should surface the fetch error:\n%s", out)
}
}
+30 -26
View File
@@ -1,36 +1,40 @@
package app
import "github.com/charmbracelet/lipgloss"
import (
"image/color"
"charm.land/lipgloss/v2"
)
// Palette is the "Soft" chrome from docs/visual/ref with the blue accent:
// rounded borders, opaque dark panels, generous spacing.
type Palette struct {
Bg lipgloss.Color // panel/background fill
BgDeep lipgloss.Color // outermost backdrop (slightly darker)
BgPanel lipgloss.Color // inside-panel fill
Sel lipgloss.Color // selection row background
Border lipgloss.Color // idle panel border
BorderHi lipgloss.Color // active panel border
Fg lipgloss.Color // primary text
FgStrong lipgloss.Color // headings / emphasis
Dim lipgloss.Color // labels, secondary text
Faint lipgloss.Color // tertiary, hints
Accent lipgloss.Color // blue accent
Accent2 lipgloss.Color // secondary accent
Bg color.Color // panel/background fill
BgDeep color.Color // outermost backdrop (slightly darker)
BgPanel color.Color // inside-panel fill
Sel color.Color // selection row background
Border color.Color // idle panel border
BorderHi color.Color // active panel border
Fg color.Color // primary text
FgStrong color.Color // headings / emphasis
Dim color.Color // labels, secondary text
Faint color.Color // tertiary, hints
Accent color.Color // blue accent
Accent2 color.Color // secondary accent
OK lipgloss.Color
Warn lipgloss.Color
Bad lipgloss.Color
OK color.Color
Warn color.Color
Bad color.Color
DiffAdd lipgloss.Color // added-line cell background
DiffDel lipgloss.Color // removed-line cell background
DiffAdd color.Color // added-line cell background
DiffDel color.Color // removed-line cell background
CatLifecycle lipgloss.Color
CatContext lipgloss.Color
CatInference lipgloss.Color
CatTool lipgloss.Color
CatDomain lipgloss.Color
CatApproval lipgloss.Color
CatLifecycle color.Color
CatContext color.Color
CatInference color.Color
CatTool color.Color
CatDomain color.Color
CatApproval color.Color
}
// SoftBlue mirrors the reference Soft theme (#14161a bg, #ced3da fg) with the
@@ -91,7 +95,7 @@ type Theme struct {
// NewTheme builds the style set for a palette.
func NewTheme(p Palette) Theme {
base := lipgloss.NewStyle().Background(p.Bg).Foreground(p.Fg)
roundBase := func(border lipgloss.Color) lipgloss.Style {
roundBase := func(border color.Color) lipgloss.Style {
return lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(border).
@@ -128,7 +132,7 @@ func NewTheme(p Palette) Theme {
}
// categoryColor maps an event category label to its accent color.
func (t Theme) categoryColor(cat string) lipgloss.Color {
func (t Theme) categoryColor(cat string) color.Color {
switch cat {
case "Lifecycle":
return t.P.CatLifecycle
@@ -0,0 +1,47 @@
package app
import (
"strings"
"testing"
)
// The collapsed tool row for a write/edit must summarise the diff by its row count (what
// ^x shows), not the raw diff byte length that used to read as a meaningless "N chars".
func TestToolRowSummary_DiffReportsRows(t *testing.T) {
diff := "--- a/f\n+++ b/f\n@@ -1,2 +1,3 @@\n context\n-old line\n+new line\n+added line\n"
got := toolRowSummary(diff)
wantRows := previewRowCount(diff)
if !strings.Contains(got, "diff (") || !strings.Contains(got, itoa(wantRows)+" rows") {
t.Fatalf("diff summary = %q, want a diff row count of %d", got, wantRows)
}
if strings.Contains(got, "tool output") {
t.Errorf("a diff should not be labelled 'tool output': %q", got)
}
}
// Non-diff output reports a true character (rune) count, not a byte count.
func TestToolRowSummary_NonDiffCountsRunes(t *testing.T) {
content := "café — déjà" // 11 runes, but 14 bytes (é, —, à are multi-byte)
got := toolRowSummary(content)
if !strings.Contains(got, "(11 chars)") {
t.Fatalf("non-diff summary = %q, want 11 chars (runes, not bytes)", got)
}
}
// A changed line whose content starts with "++"/"--" must still be counted (and rendered):
// requiring the trailing space on the file-header guard prevents mistaking it for a header.
func TestDiffSummary_CountsDoublePrefixContentLines(t *testing.T) {
// The added line's content is "++flag"; in the diff that's "+" + "++flag" = "+++flag".
diff := "--- a/f\n+++ b/f\n@@ -0,0 +1,2 @@\n+++flag\n+normal\n"
_, added, removed := diffSummary(diff)
if added != 2 {
t.Errorf("added = %d, want 2 (the '+++flag' content line must count)", added)
}
if removed != 0 {
t.Errorf("removed = %d, want 0", removed)
}
// The renderer must keep the line too (2 add rows), not drop it as a header.
if n := previewRowCount(diff); n != 2 {
t.Errorf("rendered rows = %d, want 2", n)
}
}
@@ -0,0 +1,67 @@
package app
import "testing"
func transcriptModel(roles ...string) Model {
m := NewModel(nil)
m.selectedID = "s1"
msgs := make([]RouterEntry, len(roles))
for i, r := range roles {
msgs[i] = RouterEntry{Role: r, Content: r + itoa(i)}
}
m.routerMessages["s1"] = msgs
return m
}
// ctrl+↑/↓ jumps between USER messages: from no selection ↑ grabs the most recent user
// message, keeps stepping to older ones (clamping), and ↓ steps back down, dropping to
// tail-follow (-1) once past the last user message.
func TestTranscriptNavUser(t *testing.T) {
// indices: 0 1 2 3 4
m := transcriptModel("user", "router", "user", "router", "user")
if m.transcriptSel != -1 {
t.Fatalf("initial sel = %d, want -1", m.transcriptSel)
}
m.transcriptNavUser(-1) // up from bottom → newest user (idx 4)
if m.transcriptSel != 4 {
t.Fatalf("up#1 = %d, want 4", m.transcriptSel)
}
m.transcriptNavUser(-1) // → user idx 2
if m.transcriptSel != 2 {
t.Fatalf("up#2 = %d, want 2", m.transcriptSel)
}
m.transcriptNavUser(-1) // → user idx 0
if m.transcriptSel != 0 {
t.Fatalf("up#3 = %d, want 0", m.transcriptSel)
}
m.transcriptNavUser(-1) // clamp at oldest
if m.transcriptSel != 0 {
t.Fatalf("up#4 (clamp) = %d, want 0", m.transcriptSel)
}
m.transcriptNavUser(1) // → user idx 2
if m.transcriptSel != 2 {
t.Fatalf("down#1 = %d, want 2", m.transcriptSel)
}
m.transcriptNavUser(1) // → user idx 4
m.transcriptNavUser(1) // past last user → tail-follow
if m.transcriptSel != -1 {
t.Fatalf("down past last = %d, want -1 (tail-follow)", m.transcriptSel)
}
}
// `y` copies the selected message, or the latest user/router turn when nothing is selected.
func TestCopyText(t *testing.T) {
m := transcriptModel("user", "router", "tool")
// no selection → most recent user/router turn (the "router" at idx 1, since "tool"
// is skipped).
if got := m.copyText(); got != "router1" {
t.Fatalf("copyText (no sel) = %q, want %q", got, "router1")
}
m.transcriptSel = 0
if got := m.copyText(); got != "user0" {
t.Fatalf("copyText (sel idx 0) = %q, want %q", got, "user0")
}
}
File diff suppressed because it is too large Load Diff
+435 -163
View File
@@ -2,10 +2,13 @@ package app
import (
"fmt"
"image/color"
"os"
"strings"
"time"
"github.com/charmbracelet/lipgloss"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
)
@@ -27,8 +30,63 @@ const (
// and full-width chrome.
func (m Model) narrow() bool { return m.width < narrowWidth }
// View renders the whole frame purely from the model (immediate-mode).
func (m Model) View() string {
// cursorMarker is a private-use rune the composer drops into the caret cell so
// View() can locate it in the fully-composited frame and place a real terminal
// cursor there (see splitCursor). It measures one display column — same as the
// "▏" glyph it stands in for — so the layout is identical whether it resolves to
// a live cursor or the static glyph.
const cursorMarker = ""
// View renders the whole frame purely from the model (immediate-mode). In
// bubbletea v2 the View carries terminal state, so renderRaw() builds the string
// and View() resolves the caret to a real blinking cursor + sets the window title.
func (m Model) View() tea.View {
content, cur := splitCursor(m.renderRaw(), " ")
return tea.View{Content: content, AltScreen: true, Cursor: cur, WindowTitle: m.windowTitle()}
}
// render returns the frame as a plain string with the caret drawn as the static
// "▏" glyph — for the preview tool and golden tests, which have no live cursor.
func (m Model) render() string {
content, _ := splitCursor(m.renderRaw(), "▏")
return content
}
// splitCursor finds the caret marker in a composited frame, replaces it with repl
// (a space for the live cursor cell, "▏" for static), and returns the cursor's
// screen position — nil when no marker is present (e.g. vim normal mode), which
// tells bubbletea to hide the cursor.
func splitCursor(raw, repl string) (string, *tea.Cursor) {
idx := strings.Index(raw, cursorMarker)
if idx < 0 {
return raw, nil
}
pre := raw[:idx]
y := strings.Count(pre, "\n")
lineStart := strings.LastIndex(pre, "\n") + 1 // 0 when the marker is on the first line
x := ansi.StringWidth(raw[lineStart:idx])
clean := strings.Replace(raw, cursorMarker, repl, 1)
cur := tea.NewCursor(x, y)
cur.Shape = tea.CursorBar
return clean, cur
}
// windowTitle names the terminal window/tab after the active session, falling
// back to the model name and then the bare program name.
func (m Model) windowTitle() string {
if m.displayState() == StateInSession {
if s := m.session(m.selectedID); s != nil && s.Name != "" {
return "correx — " + s.Name
}
}
if m.currentModel != "" {
return "correx — " + m.currentModel
}
return "correx"
}
// renderRaw builds the frame string (caret rendered as cursorMarker in insert mode).
func (m Model) renderRaw() string {
if m.quitting {
return ""
}
@@ -36,11 +94,15 @@ func (m Model) View() string {
return m.theme.Screen.Render("correx — terminal too small")
}
bottom := m.renderInput()
bottomH := inputH
if m.displayState() == StateApproval {
ds := m.displayState()
bottom, bottomH := m.renderInput(), m.inputBarHeight()
switch {
case ds == StateApproval:
bottomH = m.approvalBandHeight()
bottom = m.renderApprovalBand(bottomH)
case ds == StateIdle:
// Launcher: the input is centered inside the main area, so there's no bottom bar.
bottom, bottomH = "", 0
}
mainH := m.height - statusH - footerH - bottomH
@@ -48,12 +110,14 @@ func (m Model) View() string {
mainH = 3
}
base := lipgloss.JoinVertical(lipgloss.Left,
m.renderStatus(),
m.renderMain(mainH),
bottom,
m.renderFooter(),
)
sections := []string{m.renderStatus(), m.renderMain(mainH)}
if bottomH > 0 {
sections = append(sections, bottom)
}
sections = append(sections, m.renderFooter())
base := lipgloss.JoinVertical(lipgloss.Left, sections...)
// Stash the base so center() can composite a modal over a dimmed copy of it.
m.lastBase = base
if m.displayState() == StateClarification {
return m.center(m.clarificationModal())
@@ -72,7 +136,7 @@ func (m Model) View() string {
func (m Model) renderStatus() string {
t := m.theme
bg := t.P.BgPanel
span := func(s string, fg lipgloss.Color) string {
span := func(s string, fg color.Color) string {
return lipgloss.NewStyle().Foreground(fg).Background(bg).Render(s)
}
nrw := m.narrow()
@@ -93,17 +157,18 @@ 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 != "" {
if s.CurrentStage != "" && m.sbShow("stage") {
parts = append(parts, span("⟐ "+s.CurrentStage, t.P.Accent2))
}
if s.Status != "" {
if s.Status != "" && m.sbShow("status") {
parts = append(parts, lipgloss.NewStyle().Foreground(statusColor(t, s.Status)).Background(bg).Render(statusLabel(s.Status)))
}
if s.WorkspaceRoot != "" && !nrw {
if s.WorkspaceRoot != "" && !nrw && m.sbShow("workspace") {
parts = append(parts, span("⌂ "+shortPath(s.WorkspaceRoot), t.P.Faint))
}
}
if m.sbShow("model") {
model := m.currentModel
if model == "" {
model = "no model"
@@ -117,13 +182,14 @@ func (m Model) renderStatus() string {
}
parts = append(parts, span(model, t.P.Fg)+span(" ("+loc+")", t.P.Faint))
}
}
left := strings.Join(parts, sep)
var right []string
// Last-event clock (§2): always visible in-session, so a silent agent is never
// mistaken for a healthy one. Updates every frame tick; turns warn-colored when an
// active session has gone quiet past the stale threshold.
if s := m.session(m.selectedID); s != nil && m.displayState() != StateIdle {
if s := m.session(m.selectedID); s != nil && m.displayState() != StateIdle && m.sbShow("clock") {
gap := nowMillis() - s.LastEventAt
clockFg := t.P.Faint
if s.Active && gap > staleEventThresholdMs {
@@ -135,7 +201,7 @@ func (m Model) renderStatus() string {
}
right = append(right, span(label, clockFg))
}
if g := m.gaugeText(); g != "" && !nrw {
if g := m.gaugeText(); g != "" && !nrw && m.sbShow("gauge") {
right = append(right, span(g, t.P.Faint))
}
if s := m.session(m.selectedID); s != nil && s.Active {
@@ -221,6 +287,16 @@ func (m Model) renderFooter() string {
switch {
case m.editMode == ModeInsert:
hints = []string{hint("enter", "send"), hint("esc", "normal"), hint("↑↓", "history")}
if m.inputMode != ModeFilter {
hints = append(hints, hint("^J", "newline"))
}
// The `/` command menu only triggers on an empty chat line (mid-line `/` is literal).
if m.inputMode == ModeRouter && m.inputBuffer == "" {
hints = append(hints, hint("/", "cmds"))
}
if m.inputMode == ModeRouter {
hints = append(hints, hint("@", "files"))
}
case m.displayState() == StateApproval:
// Approval actions live in the band itself; the footer offers navigation only.
hints = []string{hint("l", "back"), hint("e", "events"), hint("q", "quit")}
@@ -232,15 +308,20 @@ func (m Model) renderFooter() string {
hints = []string{hint("↑↓", "choose"), hint("enter", "launch"), hint("e", "custom"), hint("esc", "later")}
case m.displayState() == StateIdle:
if nrw {
hints = []string{hint("i", "name"), hint("/", "filter"), hint("w", "wf"), hint("p", "cmds"), hint("q", "quit")}
hints = []string{hint("i", "type"), hint("Tab", "wf"), hint("R", "resume"), hint("?", "keys"), hint("p", "cmds"), hint("q", "quit")}
} else {
hints = []string{hint("i", "name"), hint("/", "filter"), hint("enter", "open"), hint("jk", "move"), hint("w", "workflows"), hint("p", "cmds"), hint("q", "quit")}
hints = []string{hint("i", "type"), hint("Tab", "workflow"), hint("R", "resume"), hint("?", "keys"), hint("p", "cmds"), hint("q", "quit")}
}
default: // StateInSession
if nrw {
hints = []string{hint("i", "msg"), hint("e", "events"), hint("S", "stats"), hint("H", "health"), hint("l", "back"), hint("p", "cmds"), hint("q", "quit")}
hints = []string{hint("i", "msg"), hint("^↑↓", "msgs"), hint("y", "copy"), hint("e", "events"), hint("l", "back"), hint("p", "cmds"), hint("q", "quit")}
} else {
hints = []string{hint("i", "message"), hint("e", "events"), hint("t", "tools"), hint("S", "stats"), hint("H", "health"), hint("m", "model"), hint("s", "mode"), hint("l", "back"), hint("p", "cmds"), hint("q", "quit")}
// Trimmed to fit ~100 cols: tools/stats/model are all reachable via `p cmds`,
// so the footer keeps the high-traffic keys + the new transcript nav/copy.
hints = []string{hint("i", "message"), hint("^↑↓", "msgs"), hint("y", "copy"), hint("e", "events"), hint("d", "panel"), hint("l", "back"), hint("p", "cmds"), hint("q", "quit")}
}
if m.copiedFlash != 0 && m.frame-m.copiedFlash < copiedFlashFrames {
hints = append(hints, lipgloss.NewStyle().Foreground(t.P.OK).Background(bg).Bold(true).Render("✓ copied"))
}
if s := m.session(m.selectedID); s != nil && s.Pending != nil {
hints = append(hints, hint("a", "approval (pending)"))
@@ -262,39 +343,215 @@ func (m Model) renderFooter() string {
// --- main split ---
func (m Model) renderMain(h int) string {
if m.displayState() == StateIdle {
return m.renderLauncher(m.width, h)
}
if m.narrow() {
return m.renderMainNarrow(h)
}
leftW := m.width * 63 / 100
rightW := m.width - leftW
var leftTitle, rightTitle string
var leftBody, rightBody []string
leftActive, rightActive := false, false
switch m.displayState() {
case StateIdle:
leftTitle = "sessions"
if m.wfVisible {
leftTitle = "workflows"
leftBody = m.workflowRows(leftW - 4)
// In-session / approval. The right panel cycles (d): events, changes (token usage +
// changed files), or off (output takes the full width).
if m.rightPanel == 2 {
return m.theme.box("output", m.routerRows(m.width-4, h-2), m.width, h, true)
}
left := m.theme.box("output", m.routerRows(leftW-4, h-2), leftW, h, true)
var right string
if m.rightPanel == 1 {
right = m.theme.box("changes", m.changesRows(rightW-4, h), rightW, h, false)
} else {
leftBody = m.sessionRows(leftW - 4)
right = m.theme.box("events", m.eventRows(rightW-4, h), rightW, h, false)
}
leftActive = true
rightTitle = "welcome"
rightBody = m.welcomeRows(rightW - 4)
case StateInSession, StateApproval:
leftTitle = "output"
leftBody = m.routerRows(leftW-4, h-2)
leftActive = true
rightTitle = "events"
rightBody = m.eventRows(rightW-4, h)
return lipgloss.JoinHorizontal(lipgloss.Top, left, right)
}
left := m.theme.box(leftTitle, leftBody, leftW, h, leftActive)
right := m.theme.box(rightTitle, rightBody, rightW, h, rightActive)
return lipgloss.JoinHorizontal(lipgloss.Top, left, right)
// changesRows is the in-session "changes" panel: a token-usage line plus a git-status-style
// list of files the session has written (summed +/- from each write's diff). ^x opens the
// full diff. Drives the right panel when rightPanel == 1.
func (m Model) changesRows(w, h int) []string {
t := m.theme
turns, toks := 0, 0
for _, e := range m.routerMessages[m.selectedID] {
if e.Role == "router" && e.Metrics != nil {
turns++
toks += e.Metrics.TotalTokens
}
}
rows := []string{
t.span("tokens ", t.P.Faint) + t.span(itoa(toks), t.P.FgStrong) +
t.span(" · turns ", t.P.Faint) + t.span(itoa(turns), t.P.Dim),
"",
}
type chg struct{ add, del int }
var order []string
seen := map[string]*chg{}
for _, e := range m.routerMessages[m.selectedID] {
if e.Role != "tool" || !isUnifiedDiff(e.Content) {
continue
}
p, a, d := diffSummary(e.Content)
if seen[p] == nil {
seen[p] = &chg{}
order = append(order, p)
}
seen[p].add += a
seen[p].del += d
}
if len(order) == 0 {
return append(rows, t.span("no file changes yet", t.P.Faint))
}
rows = append(rows, t.span("changes (^x for full diff)", t.P.Faint))
for _, p := range order {
c := seen[p]
icon := lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.Bg).Render("✎ ")
delta := lipgloss.NewStyle().Foreground(t.P.OK).Background(t.P.Bg).Render("+"+itoa(c.add)) +
t.span(" ", t.P.Bg) + lipgloss.NewStyle().Foreground(t.P.Bad).Background(t.P.Bg).Render(""+itoa(c.del))
rows = append(rows, icon+t.span(truncate(p, w-16), t.P.Fg)+" "+delta)
}
return rows
}
// renderLauncher is the idle screen: a compact, opencode-style input centered in the main
// area, with a hideable right rail of status + quick keys. The session list isn't shown —
// it's a keypress away (R). The input's lower-right shows <workflow> · <model>, Tab-cycled.
func (m Model) renderLauncher(w, h int) string {
t := m.theme
railW := 26
if m.railHidden || m.narrow() || w < 66 {
railW = 0
}
leftW := w - railW
inW := leftW * 82 / 100
if inW > 84 {
inW = 84
}
if inW < 24 {
inW = 24
}
left := lipgloss.Place(leftW, h, lipgloss.Center, lipgloss.Center, m.launcherInput(inW),
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.P.Bg)))
if railW == 0 {
return left
}
rail := lipgloss.Place(railW, h, lipgloss.Left, lipgloss.Top, m.launcherRail(railW),
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.P.Bg)))
return lipgloss.JoinHorizontal(lipgloss.Top, left, rail)
}
// maxInputLines caps how many lines a growing (multi-line) input box shows; beyond it the
// box scrolls to keep the cursor's tail in view.
const maxInputLines = 6
// editorLines renders the input buffer as styled display lines with the caret at the cursor,
// so multi-line input (Ctrl+J / Alt+Enter) shows across rows. Tabs are flattened to a space.
func (m Model) editorLines() []string {
t := m.theme
caret := ""
// Only the focused composer (no modal over it) owns the live cursor; an open
// overlay keeps editMode==Insert but draws its own filter caret instead.
if m.editMode == ModeInsert && m.overlay == OverlayNone {
caret = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Render(cursorMarker)
}
buf := m.inputBuffer
cur := m.inputCursor
if cur > len(buf) {
cur = len(buf)
}
if cur < 0 {
cur = 0
}
style := func(s string) string { return t.span(strings.ReplaceAll(s, "\t", " "), t.P.FgStrong) }
before := strings.Split(buf[:cur], "\n")
after := strings.Split(buf[cur:], "\n")
var lines []string
for _, ln := range before[:len(before)-1] {
lines = append(lines, style(ln))
}
lines = append(lines, style(before[len(before)-1])+caret+style(after[0]))
for _, ln := range after[1:] {
lines = append(lines, style(ln))
}
if len(lines) > maxInputLines {
lines = lines[len(lines)-maxInputLines:]
}
return lines
}
// launcherInput renders the compact (growing) input box plus a status sub-line
// (<workflow> · <model> left, action hints right).
func (m Model) launcherInput(w int) string {
t := m.theme
insert := m.editMode == ModeInsert
prompt := lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Bold(true).Render(" ")
indent := t.span(" ", t.P.Bg)
var content []string
if m.inputBuffer == "" && !insert {
content = []string{prompt + t.span("Ask anything…", t.P.Faint)}
} else {
for i, ln := range m.editorLines() {
if i == 0 {
content = append(content, prompt+ln)
} else {
content = append(content, indent+ln)
}
}
}
box := t.box("", content, w, len(content)+2, insert)
wf := lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.Bg).Render(m.launcherWfName())
leftSub := " " + wf + t.span(" · ", t.P.Faint) + t.span(m.currentModel, t.P.Faint)
hints := t.span("Tab wf · ⌥↵/^J newline · enter ↵ ", t.P.Faint)
return box + "\n" + m.justify(leftSub, hints, w, t.P.Bg)
}
// launcherRail is the idle status + quick-keys panel on the right.
func (m Model) launcherRail(w int) string {
t := m.theme
var rows []string
if m.connected {
rows = append(rows, lipgloss.NewStyle().Foreground(t.P.OK).Background(t.P.Bg).Render("● connected"))
} else {
rows = append(rows, lipgloss.NewStyle().Foreground(t.P.Bad).Background(t.P.Bg).Render("● offline"))
}
count := t.span(plural(len(m.sessions), "session"), t.P.Dim)
if a := m.activeSessionCount(); a > 0 {
count += t.span(" · "+itoa(a)+" active", t.P.Faint)
}
key := func(k, d string) string {
return lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Bold(true).Render(padRaw(k, 4)) + t.span(d, t.P.Dim)
}
rows = append(rows, count, "",
key("i", "type"),
key("Tab", "workflow"),
key("R", "resume"),
key("?", "keys"),
key("p", "commands"),
)
return t.box("correx", rows, w, len(rows)+2, false)
}
// launcherWfName is the active launch target: "chat" (default) or a workflow id, Tab-cycled.
func (m Model) launcherWfName() string {
if m.launcherWf <= 0 || m.launcherWf > len(m.workflows) {
return "chat"
}
return m.workflows[m.launcherWf-1].ID
}
// activeSessionCount is the number of sessions not in a terminal (completed/failed) state.
func (m Model) activeSessionCount() int {
n := 0
for _, s := range m.sessions {
u := strings.ToUpper(s.Status)
if !strings.Contains(u, "COMPLETE") && !strings.Contains(u, "FAIL") {
n++
}
}
return n
}
// renderMainNarrow gives the main area a single full-width column when the terminal
@@ -303,15 +560,8 @@ func (m Model) renderMain(h int) string {
// — the strip is too valuable to drop, the constraint is width not height).
func (m Model) renderMainNarrow(h int) string {
w := m.width
if m.displayState() == StateIdle {
title := "sessions"
body := m.sessionRows(w - 4)
if m.wfVisible {
title, body = "workflows", m.workflowRows(w-4)
}
return m.theme.box(title, body, w, h, true)
}
// in-session / approval: stack output over events.
// Idle is handled by renderLauncher (which drops the rail when narrow); this only
// runs for in-session / approval: stack output over events.
outH := h * 55 / 100
if outH < 3 {
outH = 3
@@ -351,17 +601,17 @@ func (m Model) renderInput() string {
insert := m.editMode == ModeInsert
caret := t.span(" ", t.P.Bg)
if insert && m.caretVisible() {
caret = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Render("▏")
if insert && m.overlay == OverlayNone {
caret = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Render(cursorMarker)
}
var line string
var content []string
switch {
case m.inputBuffer == "" && !insert:
line = t.span(placeholder, t.P.Faint)
content = []string{t.span(placeholder, t.P.Faint)}
case m.inputBuffer == "":
line = caret + t.span(placeholder, t.P.Faint)
content = []string{caret + t.span(placeholder, t.P.Faint)}
default:
line = t.span(flattenForDisplay(m.inputBuffer), t.P.FgStrong) + caret
content = m.editorLines()
}
// status sub-line: <session/none> · <uuid> · <mode>
@@ -387,115 +637,104 @@ func (m Model) renderInput() string {
sub += t.span(" · ", t.P.Faint) +
t.span(mode, t.P.Dim)
body := []string{line, sub}
return t.box("", body, m.width, inputH, insert)
body := append(content, sub)
return t.box("", body, m.width, len(body)+2, insert)
}
// inputBarHeight is the bottom input bar's current height (it grows with multi-line input):
// content lines (capped at maxInputLines) + the status sub-line + 2 borders.
func (m Model) inputBarHeight() int {
n := 1
if m.inputBuffer != "" {
n = len(m.editorLines())
}
return n + 1 + 2
}
// --- body row builders ---
func (m Model) sessionRows(w int) []string {
t := m.theme
list := m.filteredSessions()
if len(list) == 0 {
return []string{t.span("no sessions yet", t.P.Faint), "", t.span("type a name below to begin", t.P.Faint)}
// shortDateTime formats a millis-since-epoch instant as a compact local timestamp for the
// session list: "Jan 02 15:04" within the current year, "Jan 02 2006" for older years.
// Empty for a zero/absent time (renders as no date rather than a bogus epoch).
func shortDateTime(ms int64) string {
if ms <= 0 {
return ""
}
rows := make([]string, 0, len(list))
for _, s := range list {
sel := s.ID == m.selectedID && !m.wfVisible
short := s.ID
if len(short) > 6 {
short = short[:6]
ts := time.UnixMilli(ms)
if ts.Year() != time.Now().Year() {
return ts.Format("Jan 02 2006")
}
nameFg := t.P.Fg
bar := t.span(" ", t.P.Bg)
if sel {
bar = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Render("▌ ")
nameFg = t.P.FgStrong
}
idCell := t.span("["+short+"] ", t.P.Faint)
statusCell := lipgloss.NewStyle().Foreground(statusColor(t, s.Status)).Background(t.P.Bg).Bold(true).
Render(padRaw(statusLabel(s.Status), 9))
nameCell := lipgloss.NewStyle().Foreground(nameFg).Background(t.P.Bg).Render(" " + s.Name)
stage := ""
if s.CurrentStage != "" {
stage = t.span(" ["+s.CurrentStage+"]", t.P.Dim)
}
rows = append(rows, bar+idCell+statusCell+nameCell+stage)
}
return rows
}
func (m Model) workflowRows(w int) []string {
t := m.theme
if len(m.workflows) == 0 {
return []string{t.span("no workflows advertised", t.P.Faint)}
}
rows := make([]string, 0, len(m.workflows))
for i, wf := range m.workflows {
marker := t.span(" ", t.P.Bg)
fg := t.P.Fg
if i == m.wfIndex {
marker = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Render("▸ ")
fg = t.P.FgStrong
}
rows = append(rows, marker+lipgloss.NewStyle().Foreground(fg).Background(t.P.Bg).Render(wf.ID)+t.span(" "+wf.Description, t.P.Faint))
}
return rows
}
func (m Model) welcomeRows(w int) []string {
t := m.theme
title := lipgloss.NewStyle().Foreground(t.P.FgStrong).Background(t.P.Bg).Bold(true).Render("Corre") +
lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Bold(true).Render("x") +
lipgloss.NewStyle().Foreground(t.P.FgStrong).Background(t.P.Bg).Bold(true).Render(" Agent Harness")
var status string
if m.connected {
status = lipgloss.NewStyle().Foreground(t.P.OK).Background(t.P.Bg).Render("Connected") +
t.span(" · ", t.P.Faint) + t.span(plural(len(m.sessions), "session"), t.P.Dim)
} else {
status = lipgloss.NewStyle().Foreground(t.P.Bad).Background(t.P.Bg).Render("Disconnected")
}
rows := []string{title, status, "",
t.span("Select a session from the left", t.P.Dim),
t.span("or type a name to start a new one.", t.P.Dim), ""}
if n := len(m.sessions); n > 0 {
rows = append(rows, t.span("recent:", t.P.Faint))
start := 0
if n > 5 {
start = n - 5
}
for _, s := range m.sessions[start:] {
mark := statusGlyph(t, s.Status)
rows = append(rows, t.span(" "+s.Name+" ", t.P.Fg)+t.span(formatTime(s.LastEventAt), t.P.Faint)+" "+mark)
}
}
return rows
return ts.Format("Jan 02 15:04")
}
func (m Model) routerRows(w, h int) []string {
t := m.theme
msgs := m.routerMessages[m.selectedID]
if len(msgs) == 0 {
if len(m.routerMessages[m.selectedID]) == 0 {
return []string{t.span("awaiting router response…", t.P.Faint)}
}
rows, msgStart := m.buildTranscriptRows(w)
// With a selection, scroll so the selected message is visible (top-aligned, clamped to the
// end); otherwise honour the free-scroll offset (0 = tail-follow the newest output).
if m.transcriptSel >= 0 && m.transcriptSel < len(msgStart) && len(rows) > h {
start := msgStart[m.transcriptSel]
if start > len(rows)-h {
start = len(rows) - h
}
if start < 0 {
start = 0
}
return rows[start : start+h]
}
if len(rows) > h {
off := m.outputScroll
if max := len(rows) - h; off > max {
off = max
}
if off < 0 {
off = 0
}
end := len(rows) - off
return rows[end-h : end]
}
return rows
}
// buildTranscriptRows renders the selected session's transcript to display rows (and records each
// message's first row index for scroll-to-selection). Split out so the scroll handler can measure
// the full height against the same content the renderer windows.
func (m Model) buildTranscriptRows(w int) ([]string, []int) {
t := m.theme
msgs := m.routerMessages[m.selectedID]
var rows []string
for _, e := range msgs {
msgStart := make([]int, len(msgs)) // first row index of each message, for scroll-to-selection
for mi, e := range msgs {
msgStart[mi] = len(rows)
switch e.Role {
case "user":
if mi == m.transcriptSel {
// Selected message: an inverted tag + a highlighted background bar so it's
// unmistakable which one ctrl+↑/↓ landed on (and what `y` will copy).
tag := lipgloss.NewStyle().Foreground(t.P.BgDeep).Background(t.P.Accent).Bold(true).Render(" ")
rows = append(rows, tag+" "+lipgloss.NewStyle().Foreground(t.P.FgStrong).Background(t.P.Sel).Render(e.Content))
break
}
tag := lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Bold(true).Render("")
rows = append(rows, tag+" "+t.span(e.Content, t.P.FgStrong))
case "router":
for _, ln := range wrap(e.Content, w) {
rows = append(rows, t.span(ln, t.P.Fg))
// The router turn is model output: render it as markdown (bold,
// lists, headings, code) wrapped to the panel width. glamour applies
// its own inline foreground colors; we keep the opaque panel by
// fixing each line's background. On any failure renderMarkdown hands
// back the plain content, which still flows through this path fine.
rendered := renderMarkdown(e.Content, w)
for _, ln := range strings.Split(rendered, "\n") {
rows = append(rows, lipgloss.NewStyle().Background(t.P.Bg).Render(ln))
}
if s := metricsSuffix(e.Metrics); s != "" {
rows = append(rows, t.span(s, t.P.Faint))
}
case "tool":
rows = append(rows, t.span("· tool output ("+itoaLen(e.Content)+" chars) — ^x to view", t.P.Dim))
rows = append(rows, t.span(toolRowSummary(e.Content), t.P.Dim))
case "narration_llm":
diamond := lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Bold(true).Render("◆")
lines := wrap(e.Content, w-2)
@@ -511,24 +750,23 @@ func (m Model) routerRows(w, h int) []string {
}
case "narration", "stage":
rows = append(rows, t.span(e.Content, t.P.Dim))
case "action":
// Inline "external feedback" row: a side-effecting action (tool call, write/edit,
// approval, grant) surfaced in the conversation flow. Muted via actionsHidden.
if m.actionsHidden {
break
}
icon := lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.Bg).Render(e.Icon)
rows = append(rows, " "+icon+" "+t.span(e.Content, t.P.Dim))
}
}
// keep last h rows
if len(rows) > h {
rows = rows[len(rows)-h:]
}
return rows
return rows, msgStart
}
func (m Model) eventRows(w, h int) []string {
t := m.theme
header := lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Bold(true).Render("event stream") +
t.span(" ", t.P.Bg)
if m.connected {
header += lipgloss.NewStyle().Foreground(t.P.OK).Background(t.P.Bg).Render("● live")
} else {
header += t.span("○ idle", t.P.Faint)
}
t.span(" ", t.P.Bg) + m.eventStreamBadge()
s := m.session(m.selectedID)
if s == nil || len(s.Events) == 0 {
@@ -550,6 +788,30 @@ func (m Model) eventRows(w, h int) []string {
return append([]string{header, ""}, evRows...)
}
// eventStreamBadge reflects whether the *selected session's* stream is still live, not
// just whether the socket is connected: a completed / failed / paused session emits no
// more events, so the old connection-only "● live" went stale the moment a session ended.
func (m Model) eventStreamBadge() string {
t := m.theme
if !m.connected {
return t.span("○ idle", t.P.Faint)
}
if s := m.session(m.selectedID); s != nil {
badge := func(fg color.Color, text string) string {
return lipgloss.NewStyle().Foreground(fg).Background(t.P.Bg).Render(text)
}
switch u := strings.ToUpper(s.Status); {
case strings.Contains(u, "FAIL"):
return badge(t.P.Bad, "✗ failed")
case strings.Contains(u, "COMPLETE"):
return badge(t.P.OK, "✓ done")
case strings.Contains(u, "PAUSE"):
return badge(t.P.Warn, "‖ paused")
}
}
return lipgloss.NewStyle().Foreground(t.P.OK).Background(t.P.Bg).Render("● live")
}
// --- width helpers ---
// shortPath abbreviates the user's home dir to ~ for a compact cwd in the status bar.
@@ -561,7 +823,7 @@ func shortPath(p string) string {
}
// 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 color.Color) string {
return " " + padTo(s, w-1, bg)
}
@@ -569,7 +831,7 @@ func (m Model) fill(s string, w int, bg lipgloss.Color) string {
// so the line never overflows w. The left segment is shrunk first (the right holds
// the high-signal status — connection clock / active spinner); the right is clamped
// only as a last resort. ANSI-aware so truncation can't slice through escape codes.
func (m Model) justify(left, right string, w int, bg lipgloss.Color) string {
func (m Model) justify(left, right string, w int, bg color.Color) string {
avail := w - 2 // leading + trailing space
lw := lipgloss.Width(left)
rw := lipgloss.Width(right)
@@ -595,7 +857,7 @@ func (m Model) justify(left, right string, w int, bg lipgloss.Color) string {
// padTo pads (or truncates) a possibly-styled string to visible width w, filling
// with the given background so the panel stays opaque.
func padTo(s string, w int, bg lipgloss.Color) string {
func padTo(s string, w int, bg color.Color) string {
vw := lipgloss.Width(s)
if vw == w {
return s
@@ -622,7 +884,7 @@ func padRaw(s string, w int) string {
return s + strings.Repeat(" ", w-n)
}
func statusColor(t Theme, status string) lipgloss.Color {
func statusColor(t Theme, status string) color.Color {
u := strings.ToUpper(status)
switch {
case strings.Contains(u, "FAIL"):
@@ -659,7 +921,7 @@ func statusGlyph(t Theme, status string) string {
}
// span renders text with foreground fg over the panel background.
func (t Theme) span(s string, fg lipgloss.Color) string {
func (t Theme) span(s string, fg color.Color) string {
return lipgloss.NewStyle().Foreground(fg).Background(t.P.Bg).Render(s)
}
@@ -739,7 +1001,17 @@ func itoa(n int) string {
return string(b[i:])
}
func itoaLen(s string) string { return itoa(len(s)) }
// toolRowSummary collapses a tool-output transcript entry into a one-line pointer. A
// write/edit entry holds a unified diff, so summarise it by what ^x actually shows — the
// diff's row count — instead of the raw diff byte length (which read as a meaningless
// "N chars": it counted +/-/@@/header bytes, not anything the operator cares about, and
// len() is bytes not characters). Non-diff output falls back to a true character count.
func toolRowSummary(content string) string {
if isUnifiedDiff(content) {
return "· diff (" + itoa(previewRowCount(content)) + " rows) — ^x to view"
}
return "· tool output (" + itoa(len([]rune(content))) + " chars) — ^x to view"
}
// metricsSuffix formats the faint latency+token annotation for a ROUTER turn.
func metricsSuffix(mtr *TurnMetrics) string {
+37 -1
View File
@@ -56,6 +56,8 @@ const (
TypeSessionStats = "session.stats"
TypeIdeaList = "idea.list"
TypeHealthChecks = "health.checks"
TypeFileList = "file.list"
TypeGrantList = "grant.list"
)
// ServerMessage is a flat decode of every server->client variant. Field names
@@ -154,6 +156,23 @@ type ServerMessage struct {
// idea.list — the cross-session idea board
Ideas []IdeaDto `json:"ideas"`
// file.list — the session workspace's file paths (for the @ file-ref picker)
Paths []string `json:"paths"`
// grant.list — the active cross-session (PROJECT/GLOBAL) standing grants
Grants []GrantDto `json:"grants"`
}
// GrantDto is one standing cross-session grant. Mirrors the Kotlin GrantDto.
type GrantDto struct {
GrantID string `json:"grantId"`
Scope string `json:"scope"` // "PROJECT" | "GLOBAL"
ToolName string `json:"toolName"`
ProjectID string `json:"projectId"`
Tiers []string `json:"tiers"`
Reason string `json:"reason"`
ExpiresAtMs *int64 `json:"expiresAtMs"`
}
// IdeaDto is one idea on the cross-session board. Mirrors the Kotlin IdeaDto.
@@ -410,6 +429,12 @@ func ListArtifacts(sessionID string) []byte {
return encode("ListArtifacts", map[string]any{"sessionId": sessionID})
}
// ListFiles asks the server for the session workspace's file paths (replied to with file.list),
// used to populate the @ file-reference picker.
func ListFiles(sessionID string) []byte {
return encode("ListFiles", map[string]any{"sessionId": sessionID})
}
// GetSessionStats asks the server for a session's derived metrics (replied to with session.stats).
func GetSessionStats(sessionID string) []byte {
return encode("GetSessionStats", map[string]any{"sessionId": sessionID})
@@ -471,7 +496,8 @@ func ClearModelPin() []byte {
}
// CreateGrant pre-authorizes a tool/tier so future calls skip the approval gate.
// scope is "SESSION" or "STAGE"; permittedTiers are tier names like "T3".
// scope is "SESSION", "PROJECT", or "GLOBAL"; permittedTiers are tier names like "T3".
// For PROJECT/GLOBAL the server derives the projectId from the session's workspace.
func CreateGrant(sessionID, scope string, permittedTiers []string, reason, toolName string) []byte {
return encode("CreateGrant", map[string]any{
"sessionId": sessionID,
@@ -484,6 +510,16 @@ func CreateGrant(sessionID, scope string, permittedTiers []string, reason, toolN
})
}
// RevokeGrant revokes a standing (PROJECT/GLOBAL) grant by id; replies with a fresh grant.list.
func RevokeGrant(grantID string) []byte {
return encode("RevokeGrant", map[string]any{"grantId": grantID})
}
// ListGrants requests the active cross-session grants (replied to with a grant.list).
func ListGrants() []byte {
return encode("ListGrants", map[string]any{})
}
// Hello is the first frame sent on every (re)connect. It carries the client's
// working directory so the server can bind a workspace before any session starts.
func Hello(workingDir string) []byte {
+14 -1
View File
@@ -29,6 +29,7 @@ type Status struct {
// goroutine. Incoming and Conn are drained by the UI.
type Client struct {
url string
httpBase string
send chan []byte
in chan protocol.ServerMessage
conn chan Status
@@ -36,15 +37,27 @@ type Client struct {
// New builds a client targeting ws://host:port/stream.
func New(host string, port int) *Client {
u := url.URL{Scheme: "ws", Host: hostPort(host, port), Path: "/stream"}
hp := hostPort(host, port)
u := url.URL{Scheme: "ws", Host: hp, Path: "/stream"}
return &Client{
url: u.String(),
httpBase: (&url.URL{Scheme: "http", Host: hp}).String(),
send: make(chan []byte, 64),
in: make(chan protocol.ServerMessage, 256),
conn: make(chan Status, 16),
}
}
// HTTPBase is the http://host:port origin the WS client connects to, for REST
// calls (e.g. GET /sessions) that share the server's host/port. Empty for a nil
// client (the test harness constructs Models with a nil client).
func (c *Client) HTTPBase() string {
if c == nil {
return ""
}
return c.httpBase
}
func hostPort(host string, port int) string {
h := host
if h == "" {
+3 -2
View File
@@ -8,7 +8,7 @@ import (
"fmt"
"os"
tea "github.com/charmbracelet/bubbletea"
tea "charm.land/bubbletea/v2"
"github.com/correx/tui-go/internal/app"
"github.com/correx/tui-go/internal/ws"
)
@@ -33,7 +33,8 @@ func main() {
defer cancel()
go client.Run(ctx)
p := tea.NewProgram(app.NewModel(client), tea.WithAltScreen())
// Alt-screen is requested per-frame via the View's AltScreen field in v2.
p := tea.NewProgram(app.NewModel(client))
if _, err := p.Run(); err != nil {
fmt.Fprintln(os.Stderr, "correx tui error:", err)
os.Exit(1)
-14
View File
@@ -1,14 +0,0 @@
plugins {
id 'org.jetbrains.kotlin.jvm'
id 'application'
}
application {
mainClass = 'com.correx.apps.worker.MainKt'
}
dependencies {
implementation "com.github.ajalt.clikt:clikt:5.0.1"
}
tasks.named("koverVerify").configure { enabled = false }
@@ -1,5 +0,0 @@
package com.correx.apps.worker
fun main() {
println("correx :: apps/worker")
}

Some files were not shown because too many files have changed in this diff Show More