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:
@@ -5,6 +5,7 @@ conventions = [
|
|||||||
"Dependency direction: apps -> core -> infrastructure; no cross-core imports",
|
"Dependency direction: apps -> core -> infrastructure; no cross-core imports",
|
||||||
"No bare try-catch; use runCatching and sealed domain error types",
|
"No bare try-catch; use runCatching and sealed domain error types",
|
||||||
"Every new EventPayload must be registered in the eventModule polymorphic block (Serialization.kt)",
|
"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]
|
[commands]
|
||||||
|
|||||||
@@ -76,9 +76,6 @@ current.log
|
|||||||
# Go TUI build output
|
# Go TUI build output
|
||||||
apps/tui-go/bin/
|
apps/tui-go/bin/
|
||||||
|
|
||||||
# TUI runtime logs (Kotlin TUI, pre-Go rewrite)
|
|
||||||
apps/tui/logs/
|
|
||||||
|
|
||||||
# Server runtime logs
|
# Server runtime logs
|
||||||
apps/server/logs/
|
apps/server/logs/
|
||||||
|
|
||||||
|
|||||||
+11
@@ -34,6 +34,17 @@ mutate a candidate in place, or exceed 2 gen rounds / 1 critique round. Defect d
|
|||||||
|
|
||||||
## Ready to build (unblocked)
|
## 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 →
|
- [ ] **architect contradiction-check** (role-rel §4) — L3 over decision journal + ADRs →
|
||||||
`PossibleContradictionFlag` (display-only in v1).
|
`PossibleContradictionFlag` (display-only in v1).
|
||||||
- [ ] **symbol grounding** (role-rel §3) — needs a real symbol index (file-path grounding done); also
|
- [ ] **symbol grounding** (role-rel §3) — needs a real symbol index (file-path grounding done); also
|
||||||
|
|||||||
@@ -55,3 +55,38 @@ evidence to check.
|
|||||||
`4e5e4e5`, §5 reviewer narrow-question prompt/needs `3467826`.
|
`4e5e4e5`, §5 reviewer narrow-question prompt/needs `3467826`.
|
||||||
- **TUI requirements shipped halves** — §2 last-event clock + unknown-event fallback `8b896b5`, §5.1
|
- **TUI requirements shipped halves** — §2 last-event clock + unknown-event fallback `8b896b5`, §5.1
|
||||||
deterministic command-approval parse card `65df3f4`.
|
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.SessionCommand
|
||||||
import com.correx.apps.cli.commands.StatsCommand
|
import com.correx.apps.cli.commands.StatsCommand
|
||||||
import com.correx.apps.cli.commands.StatusCommand
|
import com.correx.apps.cli.commands.StatusCommand
|
||||||
|
import com.correx.apps.cli.commands.TaskCommand
|
||||||
import com.correx.apps.cli.commands.UndoCommand
|
import com.correx.apps.cli.commands.UndoCommand
|
||||||
import com.github.ajalt.clikt.core.CliktCommand
|
import com.github.ajalt.clikt.core.CliktCommand
|
||||||
import com.github.ajalt.clikt.core.subcommands
|
import com.github.ajalt.clikt.core.subcommands
|
||||||
@@ -33,4 +34,5 @@ fun buildCli(): CorrexCli = CorrexCli().subcommands(
|
|||||||
ReplayCommand(),
|
ReplayCommand(),
|
||||||
StatsCommand(),
|
StatsCommand(),
|
||||||
HealthCommand(),
|
HealthCommand(),
|
||||||
|
TaskCommand(),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
|||||||
import io.ktor.client.request.get
|
import io.ktor.client.request.get
|
||||||
import io.ktor.client.statement.bodyAsText
|
import io.ktor.client.statement.bodyAsText
|
||||||
import io.ktor.serialization.kotlinx.json.json
|
import io.ktor.serialization.kotlinx.json.json
|
||||||
|
import java.util.Locale
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
@@ -110,9 +111,10 @@ fun renderStats(report: StatsReportDto): String {
|
|||||||
lines += " calls: ${report.inferenceCount}"
|
lines += " calls: ${report.inferenceCount}"
|
||||||
lines += " tokens: ${report.promptTokens} prompt + ${report.completionTokens} completion = " +
|
lines += " tokens: ${report.promptTokens} prompt + ${report.completionTokens} completion = " +
|
||||||
"${report.promptTokens + report.completionTokens}"
|
"${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) {
|
for (p in report.perProvider) {
|
||||||
lines += " %-20s %4d calls %6d tok %8d ms (%.1f tok/s)".format(
|
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,
|
p.provider, p.completedCount, p.promptTokens + p.completionTokens, p.totalLatencyMs, p.tokensPerSecond,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -121,7 +123,7 @@ fun renderStats(report: StatsReportDto): String {
|
|||||||
lines += "Tools"
|
lines += "Tools"
|
||||||
lines += " calls: ${report.toolCount} time: ${report.toolMs} ms"
|
lines += " calls: ${report.toolCount} time: ${report.toolMs} ms"
|
||||||
for (t in report.perTool) {
|
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 += ""
|
lines += ""
|
||||||
|
|
||||||
@@ -131,6 +133,7 @@ fun renderStats(report: StatsReportDto): String {
|
|||||||
lines += " total wait: ${report.approvalWaitMs} ms (avg ${report.avgApprovalWaitMs} ms)"
|
lines += " total wait: ${report.approvalWaitMs} ms (avg ${report.avgApprovalWaitMs} ms)"
|
||||||
for (tier in report.perTier) {
|
for (tier in report.perTier) {
|
||||||
lines += " %-4s req %d res %d wait %d ms (avg %d ms)".format(
|
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,
|
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 += "Time accounting (% of session wall time)"
|
||||||
lines += " inference: %.1f%% tools: %.1f%% approval-wait: %.1f%% other: %.1f%%".format(
|
lines += " inference: %.1f%% tools: %.1f%% approval-wait: %.1f%% other: %.1f%%".format(
|
||||||
|
Locale.ROOT,
|
||||||
report.inferencePct, report.toolPct, report.approvalWaitPct, otherPct(report),
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
|
||||||
}
|
|
||||||
@@ -20,6 +20,7 @@ dependencies {
|
|||||||
implementation project(':core:events')
|
implementation project(':core:events')
|
||||||
implementation project(':core:approvals')
|
implementation project(':core:approvals')
|
||||||
implementation project(':core:sessions')
|
implementation project(':core:sessions')
|
||||||
|
implementation project(':core:tasks')
|
||||||
implementation project(':core:kernel')
|
implementation project(':core:kernel')
|
||||||
implementation project(':core:journal')
|
implementation project(':core:journal')
|
||||||
implementation project(':core:inference')
|
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.health.HealthInspectionService
|
||||||
import com.correx.apps.server.routes.providerRoutes
|
import com.correx.apps.server.routes.providerRoutes
|
||||||
import com.correx.apps.server.routes.sessionRoutes
|
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.routes.workflowRoutes
|
||||||
import com.correx.apps.server.ws.GlobalStreamHandler
|
import com.correx.apps.server.ws.GlobalStreamHandler
|
||||||
import io.ktor.http.HttpStatusCode
|
import io.ktor.http.HttpStatusCode
|
||||||
@@ -57,5 +58,6 @@ fun Application.configureServer(module: ServerModule) {
|
|||||||
sessionRoutes(module)
|
sessionRoutes(module)
|
||||||
workflowRoutes(module)
|
workflowRoutes(module)
|
||||||
providerRoutes(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.DefaultSessionRepository
|
||||||
import com.correx.core.sessions.SessionProjector
|
import com.correx.core.sessions.SessionProjector
|
||||||
import com.correx.core.sessions.projections.replay.DefaultEventReplayer
|
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.tools.registry.ToolRegistry
|
||||||
import com.correx.core.transitions.resolution.DefaultTransitionResolver
|
import com.correx.core.transitions.resolution.DefaultTransitionResolver
|
||||||
import com.correx.core.validation.artifact.ArtifactPayloadValidator
|
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.ManifestContainmentRule
|
||||||
import com.correx.core.toolintent.rules.NetworkHostRule
|
import com.correx.core.toolintent.rules.NetworkHostRule
|
||||||
import com.correx.core.toolintent.rules.PathContainmentRule
|
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.freestyle.FreestyleDriver
|
||||||
import com.correx.apps.server.inference.summarizeWithInference
|
import com.correx.apps.server.inference.summarizeWithInference
|
||||||
import com.correx.core.kernel.orchestration.JournalCompactionService
|
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.core.inference.InferenceProvider
|
||||||
import com.correx.infrastructure.inference.llama.cpp.LlamaCppInferenceProvider
|
import com.correx.infrastructure.inference.llama.cpp.LlamaCppInferenceProvider
|
||||||
import com.correx.infrastructure.tools.DispatchingToolExecutor
|
import com.correx.infrastructure.tools.DispatchingToolExecutor
|
||||||
|
import com.correx.infrastructure.tools.task.TaskTools
|
||||||
import com.correx.infrastructure.tools.FileEditConfig
|
import com.correx.infrastructure.tools.FileEditConfig
|
||||||
import com.correx.infrastructure.tools.FileReadConfig
|
import com.correx.infrastructure.tools.FileReadConfig
|
||||||
import com.correx.infrastructure.tools.FileWriteConfig
|
import com.correx.infrastructure.tools.FileWriteConfig
|
||||||
@@ -215,6 +221,32 @@ fun main() {
|
|||||||
null
|
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(
|
val toolRegistry = InfrastructureModule.createToolRegistry(
|
||||||
buildToolConfig(
|
buildToolConfig(
|
||||||
workspaceRoot,
|
workspaceRoot,
|
||||||
@@ -223,6 +255,7 @@ fun main() {
|
|||||||
toolsConfig,
|
toolsConfig,
|
||||||
researchToolConfig,
|
researchToolConfig,
|
||||||
),
|
),
|
||||||
|
extraTools = taskTools,
|
||||||
)
|
)
|
||||||
val toolExecutor = InfrastructureModule.createToolExecutor(
|
val toolExecutor = InfrastructureModule.createToolExecutor(
|
||||||
registry = toolRegistry,
|
registry = toolRegistry,
|
||||||
@@ -238,6 +271,10 @@ fun main() {
|
|||||||
val toolCallAssessor = ToolCallAssessor(
|
val toolCallAssessor = ToolCallAssessor(
|
||||||
rules = listOf(
|
rules = listOf(
|
||||||
PathContainmentRule(),
|
PathContainmentRule(),
|
||||||
|
ReadBeforeWriteRule(),
|
||||||
|
ReferenceExistsRule(),
|
||||||
|
WriteScopeRule(),
|
||||||
|
StaleWriteRule(),
|
||||||
ManifestContainmentRule(),
|
ManifestContainmentRule(),
|
||||||
ExecInterpreterRule(toolsConfig.interpreterExecutables.toSet()),
|
ExecInterpreterRule(toolsConfig.interpreterExecutables.toSet()),
|
||||||
NetworkHostRule(
|
NetworkHostRule(
|
||||||
@@ -250,6 +287,7 @@ fun main() {
|
|||||||
val wsToolRegistryProvider = WorkspaceToolRegistryProvider { workspace ->
|
val wsToolRegistryProvider = WorkspaceToolRegistryProvider { workspace ->
|
||||||
val wsRegistry = InfrastructureModule.createToolRegistry(
|
val wsRegistry = InfrastructureModule.createToolRegistry(
|
||||||
buildToolConfigForWorkspace(workspace, shellAllowedExecutables, toolsConfig, researchToolConfig),
|
buildToolConfigForWorkspace(workspace, shellAllowedExecutables, toolsConfig, researchToolConfig),
|
||||||
|
extraTools = taskTools,
|
||||||
)
|
)
|
||||||
val wsExecutor = DispatchingToolExecutor(wsRegistry)
|
val wsExecutor = DispatchingToolExecutor(wsRegistry)
|
||||||
WorkspaceTools(registry = wsRegistry, executor = wsExecutor)
|
WorkspaceTools(registry = wsRegistry, executor = wsExecutor)
|
||||||
@@ -316,6 +354,10 @@ fun main() {
|
|||||||
} else {
|
} else {
|
||||||
null
|
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(
|
val orchestrator = DefaultSessionOrchestrator(
|
||||||
repositories = repositories,
|
repositories = repositories,
|
||||||
engines = engines,
|
engines = engines,
|
||||||
@@ -388,6 +430,16 @@ fun main() {
|
|||||||
privilegedLocations = privilegedLocations,
|
privilegedLocations = privilegedLocations,
|
||||||
allowedWorkspaceRoots = allowedWorkspaceRoots,
|
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
|
// Built from a config snapshot and reused by ConfigService's rebuild hook so toggling
|
||||||
// project.enabled / personalization.* applies live to the next session.
|
// project.enabled / personalization.* applies live to the next session.
|
||||||
fun buildProjectMemory(cfg: CorrexConfig): com.correx.apps.server.memory.ProjectMemoryService? =
|
fun buildProjectMemory(cfg: CorrexConfig): com.correx.apps.server.memory.ProjectMemoryService? =
|
||||||
@@ -420,7 +472,7 @@ fun main() {
|
|||||||
|
|
||||||
val freestyleDriver = FreestyleDriver(
|
val freestyleDriver = FreestyleDriver(
|
||||||
eventStore = eventStore,
|
eventStore = eventStore,
|
||||||
compiler = ExecutionPlanCompiler(artifactKindRegistry),
|
compiler = ExecutionPlanCompiler(artifactKindRegistry, toolRegistry.all().map { it.name }.toSet()),
|
||||||
planContent = { sid -> orchestrator.validatedArtifactContent(sid, ArtifactId("execution_plan")) },
|
planContent = { sid -> orchestrator.validatedArtifactContent(sid, ArtifactId("execution_plan")) },
|
||||||
config = defaultOrchestrationConfig,
|
config = defaultOrchestrationConfig,
|
||||||
runPhase2 = { sid, graph, cfg -> orchestrator.run(sid, graph, cfg) },
|
runPhase2 = { sid, graph, cfg -> orchestrator.run(sid, graph, cfg) },
|
||||||
@@ -438,7 +490,7 @@ fun main() {
|
|||||||
.subjects.mapValues { it.value.status }
|
.subjects.mapValues { it.value.status }
|
||||||
com.correx.apps.server.health.HealthMonitor(
|
com.correx.apps.server.health.HealthMonitor(
|
||||||
eventStore = eventStore,
|
eventStore = eventStore,
|
||||||
probes = listOf(
|
probes = listOfNotNull(
|
||||||
com.correx.apps.server.health.DiskWatermarkProbe(
|
com.correx.apps.server.health.DiskWatermarkProbe(
|
||||||
monitoredPaths = listOf(dataDir),
|
monitoredPaths = listOf(dataDir),
|
||||||
warnBytes = hc.diskWarnBytes,
|
warnBytes = hc.diskWarnBytes,
|
||||||
@@ -479,11 +531,17 @@ fun main() {
|
|||||||
workspaceResolver = workspaceResolver,
|
workspaceResolver = workspaceResolver,
|
||||||
narrationMaxPerRun = correxConfig.router.narration.maxPerRun,
|
narrationMaxPerRun = correxConfig.router.narration.maxPerRun,
|
||||||
projectMemory = projectMemory,
|
projectMemory = projectMemory,
|
||||||
|
architectContradictionChecker = architectContradictionChecker,
|
||||||
configHolder = configHolder,
|
configHolder = configHolder,
|
||||||
freestyleDriver = freestyleDriver,
|
freestyleDriver = freestyleDriver,
|
||||||
operatorProfile = operatorProfile,
|
operatorProfile = operatorProfile,
|
||||||
profileAdaptationService = profileAdaptationService,
|
profileAdaptationService = profileAdaptationService,
|
||||||
healthMonitor = healthMonitor,
|
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
|
// 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.
|
// 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.DefaultApprovalReducer
|
||||||
import com.correx.core.approvals.DefaultApprovalRepository
|
import com.correx.core.approvals.DefaultApprovalRepository
|
||||||
import com.correx.core.artifactstore.ArtifactStore
|
import com.correx.core.artifactstore.ArtifactStore
|
||||||
|
import com.correx.core.config.AgentInstructionsLoader
|
||||||
import com.correx.core.config.OperatorProfile
|
import com.correx.core.config.OperatorProfile
|
||||||
import com.correx.core.config.ProjectProfileLoader
|
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.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.EventMetadata
|
||||||
import com.correx.core.events.events.NewEvent
|
import com.correx.core.events.events.NewEvent
|
||||||
import com.correx.core.events.events.ExecutionPlanLockedEvent
|
import com.correx.core.events.events.ExecutionPlanLockedEvent
|
||||||
import com.correx.core.events.events.OperatorProfileBoundEvent
|
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.ProjectProfileBoundEvent
|
||||||
import com.correx.core.events.events.OrchestrationPausedEvent
|
import com.correx.core.events.events.OrchestrationPausedEvent
|
||||||
import com.correx.core.events.events.OrchestrationResumedEvent
|
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.EventId
|
||||||
import com.correx.core.events.types.SessionId
|
import com.correx.core.events.types.SessionId
|
||||||
import com.correx.core.events.types.StageId
|
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.execution.WorkflowResult
|
||||||
import com.correx.core.kernel.orchestration.DefaultSessionOrchestrator
|
import com.correx.core.kernel.orchestration.DefaultSessionOrchestrator
|
||||||
import com.correx.core.kernel.orchestration.OrchestrationConfig
|
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).
|
// Cross-session, repo-scoped memory. Null disables the hooks entirely (tests / static path).
|
||||||
// Rebuilt by ConfigService when project.enabled is toggled live.
|
// Rebuilt by ConfigService when project.enabled is toggled live.
|
||||||
projectMemory: com.correx.apps.server.memory.ProjectMemoryService? = null,
|
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
|
// 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.
|
// holder seeded from defaults so callers always have a value to read.
|
||||||
val configHolder: com.correx.core.config.ConfigHolder =
|
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);
|
// 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].
|
// records degraded/restored edge events on the system session, started in [start].
|
||||||
private val healthMonitor: com.correx.apps.server.health.HealthMonitor? = null,
|
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(
|
val approvalCoordinator: ApprovalCoordinator = approvalCoordinator ?: ApprovalCoordinator(
|
||||||
orchestrator = orchestrator,
|
orchestrator = orchestrator,
|
||||||
@@ -187,6 +211,80 @@ class ServerModule(
|
|||||||
// Continuous, edge-triggered health watch (observability-spec §4). Live-only like narration:
|
// 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.
|
// probes read the environment and record degraded/restored events; replay reads those facts.
|
||||||
healthMonitor?.start(moduleScope)
|
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)
|
bindProjectProfile(sessionId)
|
||||||
|
bindAgentInstructions(sessionId)
|
||||||
runCatching {
|
runCatching {
|
||||||
val result = orchestrator.run(sessionId, graph, sessionConfig)
|
val result = orchestrator.run(sessionId, graph, sessionConfig)
|
||||||
freestyleHandoff(sessionId, graph, result)
|
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
|
* 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,
|
* 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) }
|
.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 }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+70
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
+18
@@ -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()
|
||||||
|
}
|
||||||
+19
@@ -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_$]*)"""),
|
"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.
|
// 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_]*)"""),
|
"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: h1–h3 headings as "symbols" — deeper levels are noise relative to retrieval value.
|
// Markdown: h1–h3 headings as "symbols" — deeper levels are noise relative to retrieval value.
|
||||||
"md" to Regex("""(?m)^#{1,3}\s+(?<name>.+?)\s*$"""),
|
"md" to Regex("""(?m)^#{1,3}\s+(?<name>.+?)\s*$"""),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ class RealWorkspaceStateProbe : WorkspaceStateProbe {
|
|||||||
.toList()
|
.toList()
|
||||||
}
|
}
|
||||||
val digest = MessageDigest.getInstance("SHA-256")
|
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)
|
return digest.digest().joinToString("") { "%02x".format(it) }.take(FINGERPRINT_HEX_LENGTH)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+68
-6
@@ -1,8 +1,10 @@
|
|||||||
package com.correx.apps.server.narration
|
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.ApprovalRequestedEvent
|
||||||
import com.correx.core.events.events.ExecutionPlanRejectedEvent
|
import com.correx.core.events.events.ExecutionPlanRejectedEvent
|
||||||
import com.correx.core.events.events.OrchestrationPausedEvent
|
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.StageCompletedEvent
|
||||||
import com.correx.core.events.events.StageFailedEvent
|
import com.correx.core.events.events.StageFailedEvent
|
||||||
import com.correx.core.events.events.StoredEvent
|
import com.correx.core.events.events.StoredEvent
|
||||||
@@ -20,6 +22,7 @@ import kotlinx.coroutines.flow.launchIn
|
|||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
|
import java.util.Collections
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
class NarrationSubscriber(
|
class NarrationSubscriber(
|
||||||
@@ -28,7 +31,27 @@ class NarrationSubscriber(
|
|||||||
private val scope: CoroutineScope,
|
private val scope: CoroutineScope,
|
||||||
private val maxPerRun: Int = DEFAULT_MAX_PER_RUN,
|
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>()
|
private val lanes = ConcurrentHashMap<String, SessionLane>()
|
||||||
|
|
||||||
@@ -50,6 +73,7 @@ class NarrationSubscriber(
|
|||||||
instruction = approvalInstruction(p),
|
instruction = approvalInstruction(p),
|
||||||
stageId = p.stageId?.value,
|
stageId = p.stageId?.value,
|
||||||
),
|
),
|
||||||
|
pauseKey = pauseKeyForRequest(p.requestId.value),
|
||||||
)
|
)
|
||||||
is StageCompletedEvent -> enqueue(
|
is StageCompletedEvent -> enqueue(
|
||||||
sid,
|
sid,
|
||||||
@@ -100,9 +124,16 @@ class NarrationSubscriber(
|
|||||||
instruction = "Orchestration paused in stage ${p.stageId.value}: ${p.reason}. Inform the user.",
|
instruction = "Orchestration paused in stage ${p.stageId.value}: ${p.reason}. Inform the user.",
|
||||||
stageId = p.stageId.value,
|
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
|
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) }
|
val lane = lanes.computeIfAbsent(sessionId.value) { startLane(sessionId) }
|
||||||
if (lane.used >= maxPerRun) {
|
if (lane.used >= maxPerRun) {
|
||||||
log.debug("narration budget reached for session {}; skipping {}", sessionId.value, trigger.kind)
|
log.debug("narration budget reached for session {}; skipping {}", sessionId.value, trigger.kind)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
lane.used += 1
|
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 {
|
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 {
|
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) }
|
runCatching { routerFacade.narrate(sessionId, trigger) }
|
||||||
.onFailure { e ->
|
.onFailure { e ->
|
||||||
if (e is CancellationException) throw e
|
if (e is CancellationException) throw e
|
||||||
@@ -148,12 +205,17 @@ class NarrationSubscriber(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return SessionLane(channel, used = 0)
|
return lane
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val DEFAULT_MAX_PER_RUN = 100
|
private const val DEFAULT_MAX_PER_RUN = 100
|
||||||
private const val MAX_PREVIEW_CHARS = 800
|
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 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.events.ClarificationAnswer
|
||||||
import com.correx.core.events.types.ApprovalRequestId
|
import com.correx.core.events.types.ApprovalRequestId
|
||||||
import com.correx.core.events.types.ClarificationRequestId
|
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.SessionId
|
||||||
import com.correx.core.events.types.StageId
|
import com.correx.core.events.types.StageId
|
||||||
import com.correx.core.router.ChatMode
|
import com.correx.core.router.ChatMode
|
||||||
@@ -21,6 +22,11 @@ enum class ApprovalDecision {
|
|||||||
enum class GrantScopeDto {
|
enum class GrantScopeDto {
|
||||||
SESSION,
|
SESSION,
|
||||||
STAGE,
|
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
|
@Serializable
|
||||||
@@ -44,6 +50,10 @@ sealed class ClientMessage {
|
|||||||
@Serializable
|
@Serializable
|
||||||
data class ListArtifacts(val sessionId: SessionId) : ClientMessage()
|
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). */
|
/** Operator request for a session's derived metrics (replied to with a SessionStats). */
|
||||||
@Serializable
|
@Serializable
|
||||||
data class GetSessionStats(val sessionId: SessionId) : ClientMessage()
|
data class GetSessionStats(val sessionId: SessionId) : ClientMessage()
|
||||||
@@ -60,6 +70,14 @@ sealed class ClientMessage {
|
|||||||
@Serializable
|
@Serializable
|
||||||
data class DiscardIdea(val ideaId: String) : ClientMessage()
|
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). */
|
/** Operator request for the current editable config (replied to with a ConfigSnapshot). */
|
||||||
@Serializable
|
@Serializable
|
||||||
data object GetConfig : ClientMessage()
|
data object GetConfig : ClientMessage()
|
||||||
@@ -92,10 +110,20 @@ sealed class ClientMessage {
|
|||||||
val permittedTiers: List<Tier>,
|
val permittedTiers: List<Tier>,
|
||||||
val reason: String,
|
val reason: String,
|
||||||
val expiresAt: Instant? = null,
|
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,
|
val toolName: String? = null,
|
||||||
) : ClientMessage()
|
) : 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
|
@Serializable
|
||||||
data class Ping(val timestamp: Long) : ClientMessage()
|
data class Ping(val timestamp: Long) : ClientMessage()
|
||||||
|
|
||||||
|
|||||||
@@ -121,6 +121,23 @@ data class IdeaDto(
|
|||||||
val capturedAtMs: Long,
|
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(
|
internal fun RiskSummary.toDto(): RiskSummaryDto = RiskSummaryDto(
|
||||||
level = level.name,
|
level = level.name,
|
||||||
factors = signals.map { signal ->
|
factors = signals.map { signal ->
|
||||||
|
|||||||
@@ -465,6 +465,19 @@ sealed interface ServerMessage {
|
|||||||
override val sessionSequence: Long? = null,
|
override val sessionSequence: Long? = null,
|
||||||
) : ServerMessage, NonEventMessage
|
) : 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
|
* 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.
|
* snapshot read folded from the whole event log), so cursors are null.
|
||||||
@@ -477,6 +490,18 @@ sealed interface ServerMessage {
|
|||||||
override val sessionSequence: Long? = null,
|
override val sessionSequence: Long? = null,
|
||||||
) : ServerMessage, NonEventMessage
|
) : 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
|
* 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
|
* 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.ServerModule
|
||||||
import com.correx.apps.server.metrics.MetricsInspectionService
|
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.protocol.SessionConfigDto
|
||||||
import com.correx.apps.server.replay.ReplayInspectionService
|
import com.correx.apps.server.replay.ReplayInspectionService
|
||||||
import com.correx.apps.server.serialization.payloadDiscriminator
|
import com.correx.apps.server.serialization.payloadDiscriminator
|
||||||
@@ -56,6 +58,12 @@ data class SessionStateResponse(val sessionId: String, val status: String, val c
|
|||||||
@Serializable
|
@Serializable
|
||||||
data class StartSessionResponse(val sessionId: String)
|
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")
|
private val log = LoggerFactory.getLogger("com.correx.apps.server.routes.SessionRoutes")
|
||||||
|
|
||||||
fun Route.sessionRoutes(module: ServerModule) {
|
fun Route.sessionRoutes(module: ServerModule) {
|
||||||
@@ -79,6 +87,7 @@ fun Route.sessionRoutes(module: ServerModule) {
|
|||||||
route("/{id}") {
|
route("/{id}") {
|
||||||
getSessionRoute(module)
|
getSessionRoute(module)
|
||||||
cancelSessionRoute(module)
|
cancelSessionRoute(module)
|
||||||
|
approveSourcesRoute(module)
|
||||||
undoSessionRoute(module)
|
undoSessionRoute(module)
|
||||||
resumeSessionRoute(module)
|
resumeSessionRoute(module)
|
||||||
getEventsRoute(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) {
|
private fun Route.undoSessionRoute(module: ServerModule) {
|
||||||
post("/undo") {
|
post("/undo") {
|
||||||
val id = call.parameters["id"]
|
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(),
|
||||||
|
)
|
||||||
+36
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
+53
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
+33
@@ -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.StageToolDecl
|
||||||
import com.correx.apps.server.protocol.ToolDecl
|
import com.correx.apps.server.protocol.ToolDecl
|
||||||
import com.correx.apps.server.workspace.WorkspaceResolution
|
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.GrantScope
|
||||||
import com.correx.core.approvals.Tier
|
|
||||||
import com.correx.core.events.events.ApprovalGrantCreatedEvent
|
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.ChatSessionStartedEvent
|
||||||
import com.correx.core.events.events.EventMetadata
|
import com.correx.core.events.events.EventMetadata
|
||||||
import com.correx.core.events.events.IdeaDiscardedEvent
|
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.InitialIntentEvent
|
||||||
import com.correx.core.events.events.NewEvent
|
import com.correx.core.events.events.NewEvent
|
||||||
import com.correx.core.events.events.SessionWorkspaceBoundEvent
|
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.events.types.StageId
|
||||||
import com.correx.core.inference.ProviderHealth
|
import com.correx.core.inference.ProviderHealth
|
||||||
import com.correx.core.kernel.orchestration.WorkspaceContext
|
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.router.IdeaReader
|
||||||
import com.correx.core.utils.TypeId
|
import com.correx.core.utils.TypeId
|
||||||
import com.correx.infrastructure.inference.commons.ResourceProbe
|
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) }
|
.onFailure { log.error("cancel failed for session={}: {}", msg.sessionId.value, it.message, it) }
|
||||||
}
|
}
|
||||||
is ClientMessage.ListArtifacts -> sendFrame(queries.listArtifacts(msg.sessionId))
|
is ClientMessage.ListArtifacts -> sendFrame(queries.listArtifacts(msg.sessionId))
|
||||||
|
is ClientMessage.ListFiles -> sendFrame(queries.listFiles(msg.sessionId))
|
||||||
is ClientMessage.ListIdeas -> sendFrame(queries.listIdeas())
|
is ClientMessage.ListIdeas -> sendFrame(queries.listIdeas())
|
||||||
is ClientMessage.DiscardIdea -> handleDiscardIdea(msg, sendFrame)
|
is ClientMessage.DiscardIdea -> handleDiscardIdea(msg, sendFrame)
|
||||||
|
is ClientMessage.PromoteIdea -> handlePromoteIdea(msg, sendFrame)
|
||||||
is ClientMessage.GetSessionStats -> sendFrame(queries.sessionStats(msg.sessionId))
|
is ClientMessage.GetSessionStats -> sendFrame(queries.sessionStats(msg.sessionId))
|
||||||
is ClientMessage.GetHealthChecks -> sendFrame(queries.healthChecks())
|
is ClientMessage.GetHealthChecks -> sendFrame(queries.healthChecks())
|
||||||
is ClientMessage.GetConfig -> sendFrame(queries.configSnapshot(error = null, restartRequired = emptyList()))
|
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.ApprovalResponse -> handleApprovalResponse(msg, sendFrame)
|
||||||
is ClientMessage.ClarificationResponse -> handleClarificationResponse(msg)
|
is ClientMessage.ClarificationResponse -> handleClarificationResponse(msg)
|
||||||
is ClientMessage.CreateGrant -> handleCreateGrant(msg, sendFrame)
|
is ClientMessage.CreateGrant -> handleCreateGrant(msg, sendFrame)
|
||||||
|
is ClientMessage.RevokeGrant -> handleRevokeGrant(msg, sendFrame)
|
||||||
|
is ClientMessage.ListGrants -> sendFrame(queries.listGrants())
|
||||||
is ClientMessage.ChatInput -> {
|
is ClientMessage.ChatInput -> {
|
||||||
// The USER and ROUTER turns are emitted as ChatTurnEvents inside onUserInput
|
// The USER and ROUTER turns are emitted as ChatTurnEvents inside onUserInput
|
||||||
// and reach the client as chat.turn frames via streamGlobal — no direct
|
// 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())
|
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 errorResponse(message: String) = ServerMessage.ProtocolError(message)
|
||||||
|
|
||||||
private fun encodeError(message: String): Frame.Text =
|
private fun encodeError(message: String): Frame.Text =
|
||||||
@@ -346,17 +406,19 @@ class GlobalStreamHandler(private val module: ServerModule) {
|
|||||||
msg: ClientMessage.CreateGrant,
|
msg: ClientMessage.CreateGrant,
|
||||||
sendFrame: suspend (ServerMessage) -> Unit,
|
sendFrame: suspend (ServerMessage) -> Unit,
|
||||||
) {
|
) {
|
||||||
// Validate: tiers must be non-empty and bounded to T2 (server-side ceiling).
|
// No tier ceiling: the operator explicitly opted out of the prior T2 cap, so a grant may
|
||||||
// T3/T4 are destructive/escalated tiers that must never be auto-approved via grants.
|
// 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()) {
|
if (msg.permittedTiers.isEmpty()) {
|
||||||
sendFrame(errorResponse("CreateGrant: permittedTiers must not be empty"))
|
sendFrame(errorResponse("CreateGrant: permittedTiers must not be empty"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val maxGrantableTier = Tier.T2
|
|
||||||
val overBroad = msg.permittedTiers.filter { it.level > maxGrantableTier.level }
|
suspend fun requireToolName(scopeLabel: String): String? =
|
||||||
if (overBroad.isNotEmpty()) {
|
msg.toolName?.takeIf { it.isNotBlank() }
|
||||||
sendFrame(errorResponse("CreateGrant: tiers ${overBroad.map { it.name }} exceed maximum grantable tier ${maxGrantableTier.name}"))
|
?: run {
|
||||||
return
|
sendFrame(errorResponse("CreateGrant: $scopeLabel scope requires toolName to prevent blanket approval"))
|
||||||
|
null
|
||||||
}
|
}
|
||||||
|
|
||||||
val scope = when (msg.scope) {
|
val scope = when (msg.scope) {
|
||||||
@@ -365,12 +427,7 @@ class GlobalStreamHandler(private val module: ServerModule) {
|
|||||||
sendFrame(errorResponse("CreateGrant: SESSION scope must not include stageId"))
|
sendFrame(errorResponse("CreateGrant: SESSION scope must not include stageId"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Require toolName — blanket session grants (no operation scope) are rejected.
|
GrantScope.SESSION(requireToolName("SESSION") ?: return)
|
||||||
val toolName = msg.toolName?.takeIf { it.isNotBlank() } ?: run {
|
|
||||||
sendFrame(errorResponse("CreateGrant: SESSION scope requires toolName to prevent blanket approval"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
GrantScope.SESSION(toolName)
|
|
||||||
}
|
}
|
||||||
GrantScopeDto.STAGE -> {
|
GrantScopeDto.STAGE -> {
|
||||||
val sid = msg.stageId ?: run {
|
val sid = msg.stageId ?: run {
|
||||||
@@ -379,11 +436,25 @@ class GlobalStreamHandler(private val module: ServerModule) {
|
|||||||
}
|
}
|
||||||
GrantScope.STAGE(sid)
|
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(
|
val event = NewEvent(
|
||||||
metadata = EventMetadata(
|
metadata = EventMetadata(
|
||||||
eventId = EventId(UUID.randomUUID().toString()),
|
eventId = EventId(UUID.randomUUID().toString()),
|
||||||
sessionId = msg.sessionId,
|
sessionId = streamId,
|
||||||
timestamp = Clock.System.now(),
|
timestamp = Clock.System.now(),
|
||||||
schemaVersion = 1,
|
schemaVersion = 1,
|
||||||
causationId = null,
|
causationId = null,
|
||||||
@@ -397,11 +468,44 @@ class GlobalStreamHandler(private val module: ServerModule) {
|
|||||||
expiresAt = msg.expiresAt,
|
expiresAt = msg.expiresAt,
|
||||||
sessionId = msg.sessionId,
|
sessionId = msg.sessionId,
|
||||||
stageId = (scope as? GrantScope.STAGE)?.stageId,
|
stageId = (scope as? GrantScope.STAGE)?.stageId,
|
||||||
projectId = null,
|
projectId = (scope as? GrantScope.PROJECT)?.projectId,
|
||||||
toolName = (scope as? GrantScope.SESSION)?.toolName,
|
toolName = scope.toolNameOrNull(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
module.eventStore.append(event)
|
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(
|
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.metrics.MetricsInspectionService
|
||||||
import com.correx.apps.server.protocol.ArtifactSummaryDto
|
import com.correx.apps.server.protocol.ArtifactSummaryDto
|
||||||
import com.correx.apps.server.protocol.ConfigFieldDto
|
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.IdeaDto
|
||||||
import com.correx.apps.server.protocol.ServerMessage
|
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.router.IdeaReader
|
||||||
import com.correx.core.config.EditableConfig
|
import com.correx.core.config.EditableConfig
|
||||||
import com.correx.core.events.events.ArtifactContentStoredEvent
|
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.ArtifactValidatedEvent
|
||||||
import com.correx.core.events.events.ArtifactValidatingEvent
|
import com.correx.core.events.events.ArtifactValidatingEvent
|
||||||
import com.correx.core.events.events.InferenceCompletedEvent
|
import com.correx.core.events.events.InferenceCompletedEvent
|
||||||
|
import com.correx.core.events.events.SessionWorkspaceBoundEvent
|
||||||
import com.correx.core.events.events.StoredEvent
|
import com.correx.core.events.events.StoredEvent
|
||||||
import com.correx.core.events.types.ArtifactId
|
import com.correx.core.events.types.ArtifactId
|
||||||
import com.correx.core.events.types.SessionId
|
import com.correx.core.events.types.SessionId
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.datetime.Clock
|
||||||
|
import java.nio.file.Paths
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import kotlinx.serialization.json.JsonPrimitive
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
import kotlinx.serialization.json.jsonObject
|
import kotlinx.serialization.json.jsonObject
|
||||||
@@ -62,6 +71,70 @@ class StreamQueries(private val module: ServerModule) {
|
|||||||
return ServerMessage.HealthChecks(health = report)
|
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),
|
* 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 —
|
* 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+107
@@ -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."))
|
||||||
|
}
|
||||||
|
}
|
||||||
+33
@@ -12,6 +12,7 @@ import com.correx.core.events.types.SessionId
|
|||||||
import com.correx.infrastructure.persistence.InMemoryEventStore
|
import com.correx.infrastructure.persistence.InMemoryEventStore
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import org.junit.jupiter.api.Assertions.assertEquals
|
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.Assertions.assertTrue
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
import java.nio.file.Path
|
import java.nio.file.Path
|
||||||
@@ -121,4 +122,36 @@ class ProjectMemoryServiceReuseTest {
|
|||||||
"entries should be embedded with stateKey tag",
|
"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")
|
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
|
@Test
|
||||||
fun `extracts markdown headings as symbols`(@TempDir root: Path) {
|
fun `extracts markdown headings as symbols`(@TempDir root: Path) {
|
||||||
root.resolve("design.md").writeText(
|
root.resolve("design.md").writeText(
|
||||||
|
|||||||
+136
@@ -1,18 +1,23 @@
|
|||||||
package com.correx.apps.server.narration
|
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.approvals.Tier
|
||||||
|
import com.correx.core.events.events.ApprovalDecisionResolvedEvent
|
||||||
import com.correx.core.events.events.ApprovalRequestedEvent
|
import com.correx.core.events.events.ApprovalRequestedEvent
|
||||||
import com.correx.core.events.events.EventMetadata
|
import com.correx.core.events.events.EventMetadata
|
||||||
import com.correx.core.events.events.EventPayload
|
import com.correx.core.events.events.EventPayload
|
||||||
import com.correx.core.events.events.ExecutionPlanRejectedEvent
|
import com.correx.core.events.events.ExecutionPlanRejectedEvent
|
||||||
import com.correx.core.events.events.NewEvent
|
import com.correx.core.events.events.NewEvent
|
||||||
import com.correx.core.events.events.OrchestrationPausedEvent
|
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.StageCompletedEvent
|
||||||
import com.correx.core.events.events.StageFailedEvent
|
import com.correx.core.events.events.StageFailedEvent
|
||||||
import com.correx.core.events.events.StoredEvent
|
import com.correx.core.events.events.StoredEvent
|
||||||
import com.correx.core.events.events.WorkflowCompletedEvent
|
import com.correx.core.events.events.WorkflowCompletedEvent
|
||||||
import com.correx.core.events.events.WorkflowStartedEvent
|
import com.correx.core.events.events.WorkflowStartedEvent
|
||||||
import com.correx.core.events.stores.EventStore
|
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.ApprovalRequestId
|
||||||
import com.correx.core.events.types.EventId
|
import com.correx.core.events.types.EventId
|
||||||
import com.correx.core.events.types.SessionId
|
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.RouterFacade
|
||||||
import com.correx.core.router.model.NarrationTrigger
|
import com.correx.core.router.model.NarrationTrigger
|
||||||
import com.correx.core.router.model.RouterResponse
|
import com.correx.core.router.model.RouterResponse
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.cancel
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
@@ -231,4 +238,133 @@ class NarrationSubscriberTest {
|
|||||||
"instruction must include the source",
|
"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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+26
@@ -316,6 +316,32 @@ class ServerMessageSerializationTest {
|
|||||||
assertEquals("i1", (decoded as ClientMessage.DiscardIdea).ideaId)
|
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
|
@Test
|
||||||
fun `ClarificationResponse decodes from the client wire format`() {
|
fun `ClarificationResponse decodes from the client wire format`() {
|
||||||
val wire = """
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+72
@@ -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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
+30
@@ -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 +1,2 @@
|
|||||||
/tui-go
|
/tui-go
|
||||||
|
/preview
|
||||||
|
|||||||
@@ -7,14 +7,13 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
|
||||||
"github.com/correx/tui-go/internal/app"
|
"github.com/correx/tui-go/internal/app"
|
||||||
"github.com/muesli/termenv"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
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"
|
kind := "idle"
|
||||||
if len(os.Args) > 1 {
|
if len(os.Args) > 1 {
|
||||||
kind = os.Args[1]
|
kind = os.Args[1]
|
||||||
|
|||||||
+30
-17
@@ -1,32 +1,45 @@
|
|||||||
module github.com/correx/tui-go
|
module github.com/correx/tui-go
|
||||||
|
|
||||||
go 1.26
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/charmbracelet/bubbletea v1.3.10
|
charm.land/bubbletea/v2 v2.0.7
|
||||||
github.com/charmbracelet/lipgloss v1.1.0
|
charm.land/lipgloss/v2 v2.0.4
|
||||||
github.com/charmbracelet/x/ansi v0.11.6
|
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/gorilla/websocket v1.5.3
|
||||||
github.com/muesli/termenv v0.16.0
|
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
github.com/alecthomas/chroma/v2 v2.20.0 // indirect
|
||||||
github.com/charmbracelet/colorprofile v0.4.1 // 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/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/charmbracelet/x/term v0.2.2 // indirect
|
||||||
github.com/clipperhouse/displaywidth v0.9.0 // indirect
|
github.com/charmbracelet/x/termios v0.1.1 // indirect
|
||||||
github.com/clipperhouse/stringish v0.1.1 // indirect
|
github.com/charmbracelet/x/windows v0.2.2 // indirect
|
||||||
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
|
github.com/clipperhouse/displaywidth v0.11.0 // indirect
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||||
github.com/lucasb-eyer/go-colorful v1.3.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-isatty v0.0.20 // indirect
|
||||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
github.com/mattn/go-runewidth v0.0.23 // indirect
|
||||||
github.com/mattn/go-runewidth v0.0.19 // indirect
|
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
|
||||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
|
||||||
github.com/muesli/cancelreader v0.2.2 // 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/rivo/uniseg v0.4.7 // indirect
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
golang.org/x/sys v0.38.0 // indirect
|
github.com/yuin/goldmark v1.7.13 // indirect
|
||||||
golang.org/x/text v0.3.8 // 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
@@ -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 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
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/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
|
||||||
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
|
||||||
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
|
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||||
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
|
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||||
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
|
||||||
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
|
||||||
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
|
github.com/charmbracelet/glamour v1.0.0 h1:AWMLOVFHTsysl4WV8T8QgkQ0s/ZNZo7CiE4WKhk8l08=
|
||||||
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
|
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 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
|
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 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||||
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
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/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
|
||||||
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
|
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
|
||||||
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
|
github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
|
||||||
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
|
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
|
||||||
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
|
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
|
||||||
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
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 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
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/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
||||||
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
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-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
|
||||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
|
||||||
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||||
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
|
||||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
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/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 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
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 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
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 h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
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 h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
||||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
|
golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
|
||||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
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"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
"charm.land/lipgloss/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
// inSessionModel builds an in-session model with deliberately long fields (model name,
|
// 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) {
|
func TestViewNeverOverflowsWidth(t *testing.T) {
|
||||||
for _, w := range []int{40, 50, 60, 80, 95, 96, 110, 160} {
|
for _, w := range []int{40, 50, 60, 80, 95, 96, 110, 160} {
|
||||||
m := inSessionModel(w, 30)
|
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.overlay = OverlayStats
|
||||||
m.statsFor = m.selectedID
|
m.statsFor = m.selectedID
|
||||||
m.stats = sampleStats()
|
m.stats = sampleStats()
|
||||||
assertNoOverflow(t, m.View(), w)
|
assertNoOverflow(t, m.render(), w)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,8 +3,8 @@ package app
|
|||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "charm.land/bubbletea/v2"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"charm.land/lipgloss/v2"
|
||||||
"github.com/correx/tui-go/internal/protocol"
|
"github.com/correx/tui-go/internal/protocol"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -92,7 +92,7 @@ func (m Model) clarAnswerValue(i int, q ClarQuestion) string {
|
|||||||
|
|
||||||
// --- key handling ---
|
// --- 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)
|
s := m.session(m.selectedID)
|
||||||
if s == nil || s.Clar == nil {
|
if s == nil || s.Clar == nil {
|
||||||
return m, nil
|
return m, nil
|
||||||
@@ -102,21 +102,21 @@ func (m Model) handleClarificationKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
return m.handleClarTyping(k), nil
|
return m.handleClarTyping(k), nil
|
||||||
}
|
}
|
||||||
switch k.Type {
|
switch k.Type {
|
||||||
case tea.KeyEsc:
|
case keyEsc:
|
||||||
m.clarDismissed = true
|
m.clarDismissed = true
|
||||||
case tea.KeyUp:
|
case keyUp:
|
||||||
m.clarFocusMove(-1, qs)
|
m.clarFocusMove(-1, qs)
|
||||||
case tea.KeyDown:
|
case keyDown:
|
||||||
m.clarFocusMove(1, qs)
|
m.clarFocusMove(1, qs)
|
||||||
case tea.KeyLeft:
|
case keyLeft:
|
||||||
m.clarCursorMove(-1, qs)
|
m.clarCursorMove(-1, qs)
|
||||||
case tea.KeyRight:
|
case keyRight:
|
||||||
m.clarCursorMove(1, qs)
|
m.clarCursorMove(1, qs)
|
||||||
case tea.KeySpace:
|
case keySpace:
|
||||||
m.clarSelect(qs)
|
m.clarSelect(qs)
|
||||||
case tea.KeyEnter:
|
case keyEnter:
|
||||||
return m.clarSubmit()
|
return m.clarSubmit()
|
||||||
case tea.KeyRunes:
|
case keyRunes:
|
||||||
switch string(k.Runes) {
|
switch string(k.Runes) {
|
||||||
case "k":
|
case "k":
|
||||||
m.clarFocusMove(-1, qs)
|
m.clarFocusMove(-1, qs)
|
||||||
@@ -136,24 +136,24 @@ func (m Model) handleClarificationKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
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) {
|
if m.clarFocus >= len(m.clarText) {
|
||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
switch k.Type {
|
switch k.Type {
|
||||||
case tea.KeyEsc:
|
case keyEsc:
|
||||||
m.clarTyping = false
|
m.clarTyping = false
|
||||||
case tea.KeyEnter:
|
case keyEnter:
|
||||||
m.clarTyping = false
|
m.clarTyping = false
|
||||||
// committing custom text supersedes any chosen option for this question
|
// committing custom text supersedes any chosen option for this question
|
||||||
if m.clarFocus < len(m.clarChosen) {
|
if m.clarFocus < len(m.clarChosen) {
|
||||||
m.clarChosen[m.clarFocus] = map[int]bool{}
|
m.clarChosen[m.clarFocus] = map[int]bool{}
|
||||||
}
|
}
|
||||||
case tea.KeyBackspace:
|
case keyBackspace:
|
||||||
if t := m.clarText[m.clarFocus]; len(t) > 0 {
|
if t := m.clarText[m.clarFocus]; len(t) > 0 {
|
||||||
m.clarText[m.clarFocus] = t[:len(t)-1]
|
m.clarText[m.clarFocus] = t[:len(t)-1]
|
||||||
}
|
}
|
||||||
case tea.KeyRunes, tea.KeySpace:
|
case keyRunes, keySpace:
|
||||||
m.clarText[m.clarFocus] += string(k.Runes)
|
m.clarText[m.clarFocus] += string(k.Runes)
|
||||||
}
|
}
|
||||||
return m
|
return m
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
|
||||||
"github.com/correx/tui-go/internal/protocol"
|
"github.com/correx/tui-go/internal/protocol"
|
||||||
"github.com/correx/tui-go/internal/ws"
|
"github.com/correx/tui-go/internal/ws"
|
||||||
)
|
)
|
||||||
@@ -43,13 +42,13 @@ func TestClarificationEntersStateAndRenders(t *testing.T) {
|
|||||||
func TestClarificationSelectAndSubmit(t *testing.T) {
|
func TestClarificationSelectAndSubmit(t *testing.T) {
|
||||||
m := clarModel()
|
m := clarModel()
|
||||||
// move the option cursor to "Vue" (index 1) and select it
|
// move the option cursor to "Vue" (index 1) and select it
|
||||||
m = applyClarKey(m, tea.KeyMsg{Type: tea.KeyRight})
|
m = applyClarKey(m, keyMsg{Type: keyRight})
|
||||||
m = applyClarKey(m, tea.KeyMsg{Type: tea.KeySpace})
|
m = applyClarKey(m, keyMsg{Type: keySpace})
|
||||||
if !m.clarChosenAt(0, 1) {
|
if !m.clarChosenAt(0, 1) {
|
||||||
t.Fatalf("expected option 1 chosen, chosen=%v", m.clarChosen)
|
t.Fatalf("expected option 1 chosen, chosen=%v", m.clarChosen)
|
||||||
}
|
}
|
||||||
// submit
|
// 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 {
|
if s := m.session("s1"); s == nil || s.Clar != nil {
|
||||||
t.Fatalf("expected clarification cleared after submit")
|
t.Fatalf("expected clarification cleared after submit")
|
||||||
}
|
}
|
||||||
@@ -68,8 +67,8 @@ func TestClarificationSubmitGuardWaitsForAllAnswers(t *testing.T) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
// answer only the first question, then attempt submit
|
// answer only the first question, then attempt submit
|
||||||
m = applyClarKey(m, tea.KeyMsg{Type: tea.KeySpace})
|
m = applyClarKey(m, keyMsg{Type: keySpace})
|
||||||
m = applyClarKey(m, tea.KeyMsg{Type: tea.KeyEnter})
|
m = applyClarKey(m, keyMsg{Type: keyEnter})
|
||||||
if s := m.session("s1"); s == nil || s.Clar == nil {
|
if s := m.session("s1"); s == nil || s.Clar == nil {
|
||||||
t.Fatalf("expected clarification to remain with partial answers")
|
t.Fatalf("expected clarification to remain with partial answers")
|
||||||
}
|
}
|
||||||
@@ -80,14 +79,14 @@ func TestClarificationSubmitGuardWaitsForAllAnswers(t *testing.T) {
|
|||||||
|
|
||||||
func TestClarificationCustomTextAnswer(t *testing.T) {
|
func TestClarificationCustomTextAnswer(t *testing.T) {
|
||||||
m := clarModel()
|
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 {
|
if !m.clarTyping {
|
||||||
t.Fatalf("expected typing mode after 'e'")
|
t.Fatalf("expected typing mode after 'e'")
|
||||||
}
|
}
|
||||||
for _, r := range "Solid" {
|
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 {
|
if m.clarTyping {
|
||||||
t.Fatalf("expected typing committed")
|
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)
|
updated, _ := m.handleClarificationKey(k)
|
||||||
return updated.(Model)
|
return updated.(Model)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
"charm.land/lipgloss/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
// cmdcard.go is layer 1 of tui-requirements §5 — the deterministic command-approval
|
// cmdcard.go is layer 1 of tui-requirements §5 — the deterministic command-approval
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ package app
|
|||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "charm.land/bubbletea/v2"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"charm.land/lipgloss/v2"
|
||||||
"github.com/correx/tui-go/internal/protocol"
|
"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
|
// 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.
|
// 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 {
|
if m.configEditing {
|
||||||
switch k.Type {
|
switch k.Type {
|
||||||
case tea.KeyEsc:
|
case keyEsc:
|
||||||
m.configEditing = false
|
m.configEditing = false
|
||||||
m.configEditBuf = ""
|
m.configEditBuf = ""
|
||||||
case tea.KeyEnter:
|
case keyEnter:
|
||||||
if m.configIndex >= 0 && m.configIndex < len(m.configFields) {
|
if m.configIndex >= 0 && m.configIndex < len(m.configFields) {
|
||||||
m.configStaged[m.configFields[m.configIndex].Key] = m.configEditBuf
|
m.configStaged[m.configFields[m.configIndex].Key] = m.configEditBuf
|
||||||
}
|
}
|
||||||
m.configEditing = false
|
m.configEditing = false
|
||||||
m.configEditBuf = ""
|
m.configEditBuf = ""
|
||||||
case tea.KeyBackspace:
|
case keyBackspace:
|
||||||
if n := len(m.configEditBuf); n > 0 {
|
if n := len(m.configEditBuf); n > 0 {
|
||||||
m.configEditBuf = m.configEditBuf[:n-1]
|
m.configEditBuf = m.configEditBuf[:n-1]
|
||||||
}
|
}
|
||||||
case tea.KeyRunes, tea.KeySpace:
|
case keyRunes, keySpace:
|
||||||
m.configEditBuf += string(k.Runes)
|
m.configEditBuf += string(k.Runes)
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case k.Type == tea.KeyEsc || runeIs(k, "g"):
|
case k.Type == keyEsc || runeIs(k, "g"):
|
||||||
m.overlay = OverlayNone
|
m.overlay = OverlayNone
|
||||||
case k.Type == tea.KeyUp || runeIs(k, "k"):
|
case k.Type == keyUp || runeIs(k, "k"):
|
||||||
if m.configIndex > 0 {
|
if m.configIndex > 0 {
|
||||||
m.configIndex--
|
m.configIndex--
|
||||||
}
|
}
|
||||||
case k.Type == tea.KeyDown || runeIs(k, "j"):
|
case k.Type == keyDown || runeIs(k, "j"):
|
||||||
if m.configIndex < len(m.configFields)-1 {
|
if m.configIndex < len(m.configFields)-1 {
|
||||||
m.configIndex++
|
m.configIndex++
|
||||||
}
|
}
|
||||||
case k.Type == tea.KeyEnter || k.Type == tea.KeySpace:
|
case k.Type == keyEnter || k.Type == keySpace:
|
||||||
m = m.actOnConfigField()
|
m = m.actOnConfigField()
|
||||||
case runeIs(k, "s"):
|
case runeIs(k, "s"):
|
||||||
if len(m.configStaged) > 0 {
|
if len(m.configStaged) > 0 {
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
|
||||||
"github.com/correx/tui-go/internal/protocol"
|
"github.com/correx/tui-go/internal/protocol"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -23,7 +22,7 @@ func configModel() Model {
|
|||||||
|
|
||||||
func TestConfigToggleBoolStagesFlippedValue(t *testing.T) {
|
func TestConfigToggleBoolStagesFlippedValue(t *testing.T) {
|
||||||
m := configModel()
|
m := configModel()
|
||||||
updated, _ := m.handleConfigKey(tea.KeyMsg{Type: tea.KeyEnter})
|
updated, _ := m.handleConfigKey(keyMsg{Type: keyEnter})
|
||||||
m = updated.(Model)
|
m = updated.(Model)
|
||||||
if m.configStaged["project.enabled"] != "true" {
|
if m.configStaged["project.enabled"] != "true" {
|
||||||
t.Fatalf("want staged project.enabled=true, got %q", m.configStaged["project.enabled"])
|
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) {
|
func TestConfigEnumCycles(t *testing.T) {
|
||||||
m := configModel()
|
m := configModel()
|
||||||
m.configIndex = 1 // tui.theme
|
m.configIndex = 1 // tui.theme
|
||||||
updated, _ := m.handleConfigKey(tea.KeyMsg{Type: tea.KeyEnter})
|
updated, _ := m.handleConfigKey(keyMsg{Type: keyEnter})
|
||||||
m = updated.(Model)
|
m = updated.(Model)
|
||||||
if m.configStaged["tui.theme"] != "light" {
|
if m.configStaged["tui.theme"] != "light" {
|
||||||
t.Fatalf("want staged tui.theme=light (next after dark), got %q", m.configStaged["tui.theme"])
|
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 := configModel()
|
||||||
m.configIndex = 2 // router.generation.max_tokens
|
m.configIndex = 2 // router.generation.max_tokens
|
||||||
// Enter edit mode.
|
// Enter edit mode.
|
||||||
updated, _ := m.handleConfigKey(tea.KeyMsg{Type: tea.KeyEnter})
|
updated, _ := m.handleConfigKey(keyMsg{Type: keyEnter})
|
||||||
m = updated.(Model)
|
m = updated.(Model)
|
||||||
if !m.configEditing {
|
if !m.configEditing {
|
||||||
t.Fatalf("expected edit mode after enter on INT field")
|
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.
|
// Clear the seeded buffer and type a new value.
|
||||||
m.configEditBuf = ""
|
m.configEditBuf = ""
|
||||||
for _, r := range "1024" {
|
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)
|
m = updated.(Model)
|
||||||
}
|
}
|
||||||
updated, _ = m.handleConfigKey(tea.KeyMsg{Type: tea.KeyEnter})
|
updated, _ = m.handleConfigKey(keyMsg{Type: keyEnter})
|
||||||
m = updated.(Model)
|
m = updated.(Model)
|
||||||
if m.configEditing {
|
if m.configEditing {
|
||||||
t.Fatalf("expected edit mode to end after commit")
|
t.Fatalf("expected edit mode to end after commit")
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,43 @@ func PreviewFrame(kind string, w, h int) string {
|
|||||||
m.bgUpdates = 3
|
m.bgUpdates = 3
|
||||||
m.selectedID = "04a546aa"
|
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":
|
case "workflows":
|
||||||
m.connected = true
|
m.connected = true
|
||||||
m.currentModel = "llama-cpp:default"
|
m.currentModel = "llama-cpp:default"
|
||||||
@@ -78,14 +115,14 @@ func PreviewFrame(kind string, w, h int) string {
|
|||||||
s.CurrentStage = "write_script"
|
s.CurrentStage = "write_script"
|
||||||
s.WorkspaceRoot = "/home/kami/Programs/correx"
|
s.WorkspaceRoot = "/home/kami/Programs/correx"
|
||||||
s.Events = sampleEvents()
|
s.Events = sampleEvents()
|
||||||
s.Pending = &Approval{
|
s.enqueueApproval(&Approval{
|
||||||
RequestID: "req-1", SessionID: "04a546aa", Tier: "T3", Risk: "MEDIUM",
|
RequestID: "req-1", SessionID: "04a546aa", Tier: "T3", Risk: "MEDIUM",
|
||||||
ToolName: "file_write",
|
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",
|
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{
|
Rationale: []string{
|
||||||
"[PATH_OUTSIDE_WORKSPACE] Tool 'file_write' targets path outside workspace: /tmp/healthcheck.sh",
|
"[PATH_OUTSIDE_WORKSPACE] Tool 'file_write' targets path outside workspace: /tmp/healthcheck.sh",
|
||||||
},
|
},
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
case "approval-steer":
|
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"
|
m.steerBuffer = "also print the current distro and kernel version"
|
||||||
if s := m.session("04a546aa"); s != nil {
|
if s := m.session("04a546aa"); s != nil {
|
||||||
s.CurrentStage = "write_script"
|
s.CurrentStage = "write_script"
|
||||||
s.Pending = &Approval{
|
s.enqueueApproval(&Approval{
|
||||||
RequestID: "req-3", SessionID: "04a546aa", Tier: "T3", Risk: "MEDIUM",
|
RequestID: "req-3", SessionID: "04a546aa", Tier: "T3", Risk: "MEDIUM",
|
||||||
ToolName: "file_write",
|
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",
|
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":
|
case "approval-shell":
|
||||||
@@ -114,14 +151,14 @@ func PreviewFrame(kind string, w, h int) string {
|
|||||||
s.CurrentStage = "execute_script"
|
s.CurrentStage = "execute_script"
|
||||||
s.Events = sampleEvents()
|
s.Events = sampleEvents()
|
||||||
s.WorkspaceRoot = "/home/kami/Programs/correx"
|
s.WorkspaceRoot = "/home/kami/Programs/correx"
|
||||||
s.Pending = &Approval{
|
s.enqueueApproval(&Approval{
|
||||||
RequestID: "req-2", SessionID: "04a546aa", Tier: "T3", Risk: "HIGH",
|
RequestID: "req-2", SessionID: "04a546aa", Tier: "T3", Risk: "HIGH",
|
||||||
ToolName: "shell",
|
ToolName: "shell",
|
||||||
Preview: `{"argv":["bash","-c","curl -sf https://example.com/install.sh | sudo sh"]}`,
|
Preview: `{"argv":["bash","-c","curl -sf https://example.com/install.sh | sudo sh"]}`,
|
||||||
Rationale: []string{
|
Rationale: []string{
|
||||||
"[INTERPRETER_EXECUTION] Tool 'shell' invokes interpreter 'bash'",
|
"[INTERPRETER_EXECUTION] Tool 'shell' invokes interpreter 'bash'",
|
||||||
},
|
},
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
case "diff":
|
case "diff":
|
||||||
@@ -160,8 +197,120 @@ func PreviewFrame(kind string, w, h int) string {
|
|||||||
m.selectedID = "04a546aa"
|
m.selectedID = "04a546aa"
|
||||||
m.overlay = OverlayHealth
|
m.overlay = OverlayHealth
|
||||||
m.health = sampleHealth()
|
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 {
|
func sampleStats() *protocol.StatsDto {
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"image/color"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
"charm.land/lipgloss/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
type diffKind int
|
type diffKind int
|
||||||
@@ -53,6 +54,9 @@ func parseUnifiedDiff(diff string) []diffRow {
|
|||||||
}
|
}
|
||||||
for _, ln := range strings.Split(strings.TrimRight(diff, "\n"), "\n") {
|
for _, ln := range strings.Split(strings.TrimRight(diff, "\n"), "\n") {
|
||||||
switch {
|
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, "--- "),
|
case strings.HasPrefix(ln, "+++ "), strings.HasPrefix(ln, "--- "),
|
||||||
strings.HasPrefix(ln, "diff "), strings.HasPrefix(ln, "index "):
|
strings.HasPrefix(ln, "diff "), strings.HasPrefix(ln, "index "):
|
||||||
continue
|
continue
|
||||||
@@ -193,7 +197,7 @@ func (m Model) diffCell(num int, text string, kind diffKind, w int) string {
|
|||||||
if kind == diffBlank {
|
if kind == diffBlank {
|
||||||
return lipgloss.NewStyle().Background(t.P.Bg).Render(strings.Repeat(" ", w))
|
return lipgloss.NewStyle().Background(t.P.Bg).Render(strings.Repeat(" ", w))
|
||||||
}
|
}
|
||||||
var bg, textFg, signFg lipgloss.Color
|
var bg, textFg, signFg color.Color
|
||||||
sign := " "
|
sign := " "
|
||||||
switch kind {
|
switch kind {
|
||||||
case diffAdd:
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ package app
|
|||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
"charm.land/lipgloss/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ideacard.go renders the cross-session idea board (OverlayIdeas): the ideas the router
|
// ideacard.go renders the cross-session idea board (OverlayIdeas): the ideas the router
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
|
||||||
"github.com/correx/tui-go/internal/protocol"
|
"github.com/correx/tui-go/internal/protocol"
|
||||||
"github.com/correx/tui-go/internal/ws"
|
"github.com/correx/tui-go/internal/ws"
|
||||||
)
|
)
|
||||||
@@ -43,7 +42,7 @@ func TestIdeaRemoveSendsDiscardAndDropsRow(t *testing.T) {
|
|||||||
m, client := ideaModel()
|
m, client := ideaModel()
|
||||||
_ = client.Drain() // drop the ListIdeas frame from openIdeas
|
_ = 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)
|
m = updated.(Model)
|
||||||
|
|
||||||
if len(m.ideas) != 1 || m.ideas[0].IdeaID != "i2" {
|
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)")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/correx/tui-go/internal/protocol"
|
"github.com/correx/tui-go/internal/protocol"
|
||||||
"github.com/correx/tui-go/internal/ws"
|
"github.com/correx/tui-go/internal/ws"
|
||||||
)
|
)
|
||||||
@@ -54,12 +57,20 @@ const (
|
|||||||
OverlayStats
|
OverlayStats
|
||||||
OverlayIdeas
|
OverlayIdeas
|
||||||
OverlayHealth
|
OverlayHealth
|
||||||
|
OverlaySessions
|
||||||
|
OverlayTasks
|
||||||
|
OverlayFiles
|
||||||
|
OverlayStatusbar
|
||||||
|
OverlayGrants
|
||||||
|
OverlayGrantScope
|
||||||
|
OverlayHelp
|
||||||
)
|
)
|
||||||
|
|
||||||
// RouterEntry is one line in a session's conversation transcript.
|
// RouterEntry is one line in a session's conversation transcript.
|
||||||
type RouterEntry struct {
|
type RouterEntry struct {
|
||||||
Role string // user | router | tool | narration | narration_llm
|
Role string // user | router | tool | narration | narration_llm | action
|
||||||
Content string
|
Content string
|
||||||
|
Icon string // action role only: the gutter glyph (✓ ✎ ✗ ⌘ ✕ ⊞ ⊟)
|
||||||
Metrics *TurnMetrics
|
Metrics *TurnMetrics
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,6 +122,27 @@ type Approval struct {
|
|||||||
Rationale []string
|
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).
|
// ClarQuestion is one open question a stage raised (mirrors the wire DTO).
|
||||||
type ClarQuestion struct {
|
type ClarQuestion struct {
|
||||||
ID string
|
ID string
|
||||||
@@ -159,7 +191,12 @@ type Session struct {
|
|||||||
Tools []ToolRecord
|
Tools []ToolRecord
|
||||||
ToolsByStage map[string][]ManifestTool
|
ToolsByStage map[string][]ManifestTool
|
||||||
Events []EventEntry
|
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
|
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)
|
Clar *Clarification // open questions awaiting answers (clarification view)
|
||||||
Propose *Proposal // router workflow suggestion awaiting a pick (propose view)
|
Propose *Proposal // router workflow suggestion awaiting a pick (propose view)
|
||||||
Active bool // an inference/tool is in flight (drives the spinner)
|
Active bool // an inference/tool is in flight (drives the spinner)
|
||||||
@@ -192,6 +229,11 @@ type Model struct {
|
|||||||
wfVisible bool
|
wfVisible bool
|
||||||
wfPendingID string // workflow chosen, awaiting an intent line before StartSession
|
wfPendingID string // workflow chosen, awaiting an intent line before StartSession
|
||||||
wfPendingName string
|
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
|
bgUpdates int
|
||||||
|
|
||||||
// input
|
// input
|
||||||
@@ -199,9 +241,9 @@ type Model struct {
|
|||||||
inputMode InputMode
|
inputMode InputMode
|
||||||
inputBuffer string
|
inputBuffer string
|
||||||
inputCursor int
|
inputCursor int
|
||||||
history map[string][]string
|
inputHistory []string // submitted lines across all contexts (chat, intent, in-session), newest last
|
||||||
historyIndex int
|
historyIndex int // -1 = editing the live buffer; else an index into inputHistory
|
||||||
savedBuffer string
|
savedBuffer string // live buffer stashed while walking history
|
||||||
|
|
||||||
// flow flags
|
// flow flags
|
||||||
sessionEntered bool
|
sessionEntered bool
|
||||||
@@ -212,6 +254,11 @@ type Model struct {
|
|||||||
routerMessages map[string][]RouterEntry
|
routerMessages map[string][]RouterEntry
|
||||||
routerConnected bool
|
routerConnected bool
|
||||||
chatMode string
|
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
|
// provider
|
||||||
currentModel string
|
currentModel string
|
||||||
@@ -231,7 +278,11 @@ type Model struct {
|
|||||||
overlay OverlayKind
|
overlay OverlayKind
|
||||||
overlayEventIdx int
|
overlayEventIdx int
|
||||||
diffScrollOffset int
|
diffScrollOffset int
|
||||||
|
modalScroll int // body scroll for tall fixed-content modals (help, stats)
|
||||||
eventStripShown bool
|
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
|
// artifact viewer (OverlayArtifacts) — populated by the artifact.list response
|
||||||
artifacts []protocol.ArtifactDto
|
artifacts []protocol.ArtifactDto
|
||||||
@@ -264,20 +315,85 @@ type Model struct {
|
|||||||
health *protocol.HealthDto
|
health *protocol.HealthDto
|
||||||
healthLoading bool
|
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
|
// command palette
|
||||||
paletteFilter string
|
paletteFilter string
|
||||||
paletteIndex int
|
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
|
// animation
|
||||||
frame int // tick counter; drives spinner + caret blink
|
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
|
// snapshot phase
|
||||||
snapshotPhase bool
|
snapshotPhase bool
|
||||||
pendingEvents []protocol.ServerMessage
|
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
|
// approval steering input buffer
|
||||||
steerBuffer string
|
steerBuffer string
|
||||||
steering bool
|
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)
|
// clarification view (the interactive question form)
|
||||||
clarFocus int // focused question index
|
clarFocus int // focused question index
|
||||||
@@ -301,7 +417,6 @@ func NewModel(client *ws.Client) Model {
|
|||||||
theme: NewTheme(SoftBlue),
|
theme: NewTheme(SoftBlue),
|
||||||
wfIndex: -1,
|
wfIndex: -1,
|
||||||
inputMode: ModeRouter,
|
inputMode: ModeRouter,
|
||||||
history: map[string][]string{},
|
|
||||||
historyIndex: -1,
|
historyIndex: -1,
|
||||||
routerMessages: map[string][]RouterEntry{},
|
routerMessages: map[string][]RouterEntry{},
|
||||||
configStaged: map[string]string{},
|
configStaged: map[string]string{},
|
||||||
@@ -309,6 +424,9 @@ func NewModel(client *ws.Client) Model {
|
|||||||
providerType: "LOCAL",
|
providerType: "LOCAL",
|
||||||
snapshotPhase: true,
|
snapshotPhase: true,
|
||||||
eventStripShown: true,
|
eventStripShown: true,
|
||||||
|
transcriptSel: -1,
|
||||||
|
sbHidden: loadStatusbarHidden(),
|
||||||
|
actionsHidden: loadPrefs().InlineActionsHidden,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -348,6 +466,79 @@ func (m Model) session(id string) *Session {
|
|||||||
return nil
|
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.
|
// 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 existence is derived from the event stream (any event for an unknown
|
||||||
// session vivifies it) rather than a dedicated control frame.
|
// 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,20 +2,82 @@ package app
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"image/color"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
"charm.land/lipgloss/v2"
|
||||||
"github.com/charmbracelet/x/ansi"
|
"github.com/charmbracelet/x/ansi"
|
||||||
)
|
)
|
||||||
|
|
||||||
// center composites a modal over a scrim-filled screen. Immediate-mode: the modal
|
// center draws a modal centered over a *dimmed* copy of the screen behind it, so the
|
||||||
// is recomputed from state every frame, so there is no separate show/restore path
|
// transcript stays faintly visible around the modal (a transparent backdrop) rather
|
||||||
// to desync (the "diff won't come back" bug class can't occur here).
|
// 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 {
|
func (m Model) center(modal string) string {
|
||||||
|
if m.lastBase == "" {
|
||||||
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, modal,
|
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, modal,
|
||||||
lipgloss.WithWhitespaceBackground(m.theme.P.BgDeep),
|
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().
|
||||||
lipgloss.WithWhitespaceForeground(m.theme.P.BgDeep))
|
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 {
|
func (m Model) modalWidth() int {
|
||||||
@@ -63,6 +125,20 @@ func (m Model) renderOverlay(base string) string {
|
|||||||
return m.center(m.ideasModal())
|
return m.center(m.ideasModal())
|
||||||
case OverlayHealth:
|
case OverlayHealth:
|
||||||
return m.center(m.healthModal())
|
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
|
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)
|
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)
|
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) +
|
rightHdr := t.span("tier ", t.P.Faint) + t.span(a.Tier, t.P.Accent2) +
|
||||||
t.span(" · risk ", t.P.Faint) +
|
t.span(" · risk ", t.P.Faint) +
|
||||||
lipgloss.NewStyle().Foreground(riskColor).Background(t.P.Bg).Bold(true).Render(strings.ToUpper(a.Risk))
|
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
|
innerH := h - 2
|
||||||
ratBlock := 0
|
ratBlock := 0
|
||||||
@@ -218,8 +301,19 @@ func (m Model) approvalActions() string {
|
|||||||
hint("enter", "approve + send note"), hint("esc", "keep note, decide below"),
|
hint("enter", "approve + send note"), hint("esc", "keep note, decide below"),
|
||||||
}, gap)
|
}, gap)
|
||||||
}
|
}
|
||||||
parts := []string{hint("a", "approve"), hint("A", "auto"), hint("s", "steer"), hint("r", "reject")}
|
s := m.session(m.selectedID)
|
||||||
if s := m.session(m.selectedID); s != nil && s.Pending != nil &&
|
// 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)) {
|
(isUnifiedDiff(s.Pending.Preview) || isCommandApproval(s.Pending)) {
|
||||||
parts = append(parts, hint("^x", "fullscreen"))
|
parts = append(parts, hint("^x", "fullscreen"))
|
||||||
}
|
}
|
||||||
@@ -264,6 +358,20 @@ func (m Model) diffMaxScroll() int {
|
|||||||
return max
|
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 {
|
func (m Model) diffModal() string {
|
||||||
if cmd := m.pendingCommand(); cmd != nil {
|
if cmd := m.pendingCommand(); cmd != nil {
|
||||||
return m.commandModal(cmd)
|
return m.commandModal(cmd)
|
||||||
@@ -301,7 +409,7 @@ func (m Model) diffModal() string {
|
|||||||
for _, ln := range lines {
|
for _, ln := range lines {
|
||||||
b.WriteString(ln + "\n")
|
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())
|
modal := t.Overlay.Width(w).Render(b.String())
|
||||||
return m.center(modal)
|
return m.center(modal)
|
||||||
@@ -335,7 +443,7 @@ func (m Model) commandModal(a *Approval) string {
|
|||||||
for i := off; i < end; i++ {
|
for i := off; i < end; i++ {
|
||||||
b.WriteString(lines[i] + "\n")
|
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()))
|
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(mbg(t, " ("+itoa(m.overlayEventIdx+1)+"/"+itoa(len(evs))+")", t.P.Faint))
|
||||||
}
|
}
|
||||||
b.WriteString("\n\n")
|
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 {
|
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()))
|
return m.center(t.Overlay.Width(w).Render(b.String()))
|
||||||
}
|
}
|
||||||
// Budget the plain detail so the styled row never needs ANSI-aware truncation
|
// 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)
|
mbg(t, " "+truncate(e.Detail, detailBudget), t.P.Dim)
|
||||||
b.WriteString(row + "\n")
|
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())
|
modal := t.Overlay.Width(w).Render(b.String())
|
||||||
return m.center(modal)
|
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("▌ ")
|
marker = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▌ ")
|
||||||
titleFg = t.P.FgStrong
|
titleFg = t.P.FgStrong
|
||||||
}
|
}
|
||||||
b.WriteString(marker +
|
// keybind column: the bare-key shortcut, so the palette teaches the shortcuts.
|
||||||
lipgloss.NewStyle().Foreground(titleFg).Background(t.P.BgPanel).Render(padRaw(c.title, 18)) +
|
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")
|
mbg(t, " "+c.hint, t.P.Faint) + "\n")
|
||||||
}
|
}
|
||||||
b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "select"}, {"enter", "run"}, {"esc", "close"}}))
|
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)
|
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 {
|
func (m Model) toolPaletteModal() string {
|
||||||
t := m.theme
|
t := m.theme
|
||||||
w := m.modalWidth()
|
w := m.modalWidth()
|
||||||
@@ -550,6 +986,20 @@ func (m Model) artifactContentMaxScroll(w int) int {
|
|||||||
return max
|
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 {
|
func (m Model) artifactsModal() string {
|
||||||
t := m.theme
|
t := m.theme
|
||||||
w := m.modalWidth()
|
w := m.modalWidth()
|
||||||
@@ -628,7 +1078,7 @@ func (m Model) artifactsModal() string {
|
|||||||
b.WriteString(mbg(t, " "+lines[i], t.P.Fg) + "\n")
|
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())
|
return t.Overlay.Width(w).Render(b.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -660,14 +1110,14 @@ func (m Model) statsModal() string {
|
|||||||
t := m.theme
|
t := m.theme
|
||||||
w := m.modalWidth()
|
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
|
var b strings.Builder
|
||||||
b.WriteString(m.titleLine("session stats"))
|
b.WriteString(m.titleLine("session stats"))
|
||||||
if m.selectedID != "" {
|
if m.selectedID != "" {
|
||||||
b.WriteString(mbg(t, " ("+shortID(m.selectedID)+")", t.P.Faint))
|
b.WriteString(mbg(t, " ("+shortID(m.selectedID)+")", t.P.Faint))
|
||||||
}
|
}
|
||||||
b.WriteString("\n\n")
|
b.WriteString("\n\n")
|
||||||
|
|
||||||
if m.statsLoading || m.stats == nil || m.statsFor != m.selectedID {
|
|
||||||
msg := "loading…"
|
msg := "loading…"
|
||||||
if !m.statsLoading && m.stats == nil {
|
if !m.statsLoading && m.stats == nil {
|
||||||
msg = "no stats for this session"
|
msg = "no stats for this session"
|
||||||
@@ -677,15 +1127,29 @@ func (m Model) statsModal() string {
|
|||||||
return t.Overlay.Width(w).Render(b.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
|
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 {
|
section := func(label string) string {
|
||||||
return lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.BgPanel).Bold(true).Render(label)
|
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 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) }
|
line := func(s string) string { return mbg(t, s, t.P.Fg) }
|
||||||
faint := func(s string) string { return mbg(t, s, t.P.Faint) }
|
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 shrinks the breakdown name column on slim terminals.
|
||||||
nameW := 20
|
nameW := 20
|
||||||
if cw < 52 {
|
if cw < 52 {
|
||||||
@@ -695,10 +1159,10 @@ func (m Model) statsModal() string {
|
|||||||
// header row: duration + event count
|
// header row: duration + event count
|
||||||
put(faint(" duration ") + line(humanDurMs(s.SessionDurationMs)) +
|
put(faint(" duration ") + line(humanDurMs(s.SessionDurationMs)) +
|
||||||
faint(" events ") + line(itoa(int(s.EventCount))))
|
faint(" events ") + line(itoa(int(s.EventCount))))
|
||||||
b.WriteString("\n")
|
blank()
|
||||||
|
|
||||||
// inference
|
// inference
|
||||||
b.WriteString(section("Inference") + "\n")
|
out = append(out, section("Inference"))
|
||||||
put(line(fmt.Sprintf(" %d calls · %d+%d tok · %.1f tok/s",
|
put(line(fmt.Sprintf(" %d calls · %d+%d tok · %.1f tok/s",
|
||||||
s.InferenceCount, s.PromptTokens, s.CompletionTokens, s.TokensPerSecond)))
|
s.InferenceCount, s.PromptTokens, s.CompletionTokens, s.TokensPerSecond)))
|
||||||
for i, p := range s.PerProvider {
|
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",
|
put(faint(fmt.Sprintf(" %-*s %d× %d tok %.1f t/s",
|
||||||
nameW, padRaw(p.Provider, nameW), p.CompletedCount, p.PromptTokens+p.CompletionTokens, p.TokensPerSecond)))
|
nameW, padRaw(p.Provider, nameW), p.CompletedCount, p.PromptTokens+p.CompletionTokens, p.TokensPerSecond)))
|
||||||
}
|
}
|
||||||
b.WriteString("\n")
|
blank()
|
||||||
|
|
||||||
// tools
|
// tools
|
||||||
b.WriteString(section("Tools") + "\n")
|
out = append(out, section("Tools"))
|
||||||
put(line(fmt.Sprintf(" %d calls · %d ms", s.ToolCount, s.ToolMs)))
|
put(line(fmt.Sprintf(" %d calls · %d ms", s.ToolCount, s.ToolMs)))
|
||||||
for i, tl := range s.PerTool {
|
for i, tl := range s.PerTool {
|
||||||
if i >= statsBreakdownRows {
|
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)))
|
put(faint(fmt.Sprintf(" %-*s %d× %d ms", nameW, padRaw(tl.ToolName, nameW), tl.CompletedCount, tl.TotalDurationMs)))
|
||||||
}
|
}
|
||||||
b.WriteString("\n")
|
blank()
|
||||||
|
|
||||||
// approvals
|
// approvals
|
||||||
b.WriteString(section("Approvals") + "\n")
|
out = append(out, section("Approvals"))
|
||||||
put(line(fmt.Sprintf(" %d req · %d res · %d pending · avg %d ms",
|
put(line(fmt.Sprintf(" %d req · %d res · %d pending · avg %d ms",
|
||||||
s.ApprovalsRequested, s.ApprovalsResolved, s.ApprovalsPending, s.AvgApprovalWaitMs)))
|
s.ApprovalsRequested, s.ApprovalsResolved, s.ApprovalsPending, s.AvgApprovalWaitMs)))
|
||||||
for _, tr := range s.PerTier {
|
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)))
|
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
|
// failures
|
||||||
f := s.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",
|
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)))
|
f.InferenceFailures, f.InferenceTimeouts, f.ToolFailures, f.ToolRejections, f.StageFailures, f.WorkflowFailures)))
|
||||||
b.WriteString("\n")
|
blank()
|
||||||
|
|
||||||
// time accounting
|
// time accounting
|
||||||
other := 100.0 - s.InferencePct - s.ToolPct - s.ApprovalWaitPct
|
other := 100.0 - s.InferencePct - s.ToolPct - s.ApprovalWaitPct
|
||||||
if other < 0 {
|
if other < 0 {
|
||||||
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%%",
|
put(line(fmt.Sprintf(" inf %.1f%% · tools %.1f%% · wait %.1f%% · other %.1f%%",
|
||||||
s.InferencePct, s.ToolPct, s.ApprovalWaitPct, other)))
|
s.InferencePct, s.ToolPct, s.ApprovalWaitPct, other)))
|
||||||
|
|
||||||
b.WriteString("\n" + modalHints(t, [][2]string{{"S/esc", "close"}}))
|
return out
|
||||||
return t.Overlay.Width(w).Render(b.String())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) healthModal() string {
|
func (m Model) healthModal() string {
|
||||||
@@ -820,7 +1283,7 @@ func shortID(id string) string {
|
|||||||
return id[:13] + "…"
|
return id[:13] + "…"
|
||||||
}
|
}
|
||||||
|
|
||||||
func tierColor(t Theme, tier int) lipgloss.Color {
|
func tierColor(t Theme, tier int) color.Color {
|
||||||
switch {
|
switch {
|
||||||
case tier >= 3:
|
case tier >= 3:
|
||||||
return t.P.Bad
|
return t.P.Bad
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -3,8 +3,8 @@ package app
|
|||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "charm.land/bubbletea/v2"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"charm.land/lipgloss/v2"
|
||||||
"github.com/correx/tui-go/internal/protocol"
|
"github.com/correx/tui-go/internal/protocol"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -24,7 +24,7 @@ func (m *Model) proposeInitState() {
|
|||||||
|
|
||||||
// --- key handling ---
|
// --- 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)
|
s := m.session(m.selectedID)
|
||||||
if s == nil || s.Propose == nil {
|
if s == nil || s.Propose == nil {
|
||||||
return m, 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
|
n := len(s.Propose.Candidates) // index n == the custom slot
|
||||||
switch k.Type {
|
switch k.Type {
|
||||||
case tea.KeyEsc:
|
case keyEsc:
|
||||||
m.proposeDismissed = true
|
m.proposeDismissed = true
|
||||||
case tea.KeyUp:
|
case keyUp:
|
||||||
m.proposeCursor = clampInt(m.proposeCursor-1, 0, n)
|
m.proposeCursor = clampInt(m.proposeCursor-1, 0, n)
|
||||||
case tea.KeyDown:
|
case keyDown:
|
||||||
m.proposeCursor = clampInt(m.proposeCursor+1, 0, n)
|
m.proposeCursor = clampInt(m.proposeCursor+1, 0, n)
|
||||||
case tea.KeyEnter, tea.KeySpace:
|
case keyEnter, keySpace:
|
||||||
return m.proposeSubmit()
|
return m.proposeSubmit()
|
||||||
case tea.KeyRunes:
|
case keyRunes:
|
||||||
switch string(k.Runes) {
|
switch string(k.Runes) {
|
||||||
case "k":
|
case "k":
|
||||||
m.proposeCursor = clampInt(m.proposeCursor-1, 0, n)
|
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
|
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 {
|
switch k.Type {
|
||||||
case tea.KeyEsc:
|
case keyEsc:
|
||||||
m.proposeTyping = false
|
m.proposeTyping = false
|
||||||
case tea.KeyEnter:
|
case keyEnter:
|
||||||
return m.proposeSubmit() // commit the custom answer and send it as chat
|
return m.proposeSubmit() // commit the custom answer and send it as chat
|
||||||
case tea.KeyBackspace:
|
case keyBackspace:
|
||||||
if len(m.proposeText) > 0 {
|
if len(m.proposeText) > 0 {
|
||||||
m.proposeText = m.proposeText[:len(m.proposeText)-1]
|
m.proposeText = m.proposeText[:len(m.proposeText)-1]
|
||||||
}
|
}
|
||||||
case tea.KeyRunes, tea.KeySpace:
|
case keyRunes, keySpace:
|
||||||
m.proposeText += string(k.Runes)
|
m.proposeText += string(k.Runes)
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
|
||||||
"github.com/correx/tui-go/internal/protocol"
|
"github.com/correx/tui-go/internal/protocol"
|
||||||
"github.com/correx/tui-go/internal/ws"
|
"github.com/correx/tui-go/internal/ws"
|
||||||
)
|
)
|
||||||
@@ -47,8 +46,8 @@ func TestProposeEntersStateAndRenders(t *testing.T) {
|
|||||||
func TestProposePickLaunchesChosenWorkflow(t *testing.T) {
|
func TestProposePickLaunchesChosenWorkflow(t *testing.T) {
|
||||||
m, client := proposeModel()
|
m, client := proposeModel()
|
||||||
// move cursor to the second candidate (role_pipeline) and launch it
|
// move cursor to the second candidate (role_pipeline) and launch it
|
||||||
m = applyProposeKey(m, tea.KeyMsg{Type: tea.KeyDown})
|
m = applyProposeKey(m, keyMsg{Type: keyDown})
|
||||||
m = applyProposeKey(m, tea.KeyMsg{Type: tea.KeyEnter})
|
m = applyProposeKey(m, keyMsg{Type: keyEnter})
|
||||||
|
|
||||||
if s := m.session("s1"); s == nil || s.Propose != nil {
|
if s := m.session("s1"); s == nil || s.Propose != nil {
|
||||||
t.Fatalf("expected proposal cleared after launch")
|
t.Fatalf("expected proposal cleared after launch")
|
||||||
@@ -67,7 +66,7 @@ func TestProposePickLaunchesChosenWorkflow(t *testing.T) {
|
|||||||
|
|
||||||
func TestProposeFirstCandidateIsDefault(t *testing.T) {
|
func TestProposeFirstCandidateIsDefault(t *testing.T) {
|
||||||
m, client := proposeModel()
|
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)
|
wf, _ := decodeStart(t, client)
|
||||||
if wf != "research" {
|
if wf != "research" {
|
||||||
t.Fatalf("expected first candidate launched by default, got %q", wf)
|
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) {
|
func TestProposeCustomAnswerContinuesChat(t *testing.T) {
|
||||||
m, client := proposeModel()
|
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 {
|
if !m.proposeTyping {
|
||||||
t.Fatalf("expected typing mode after 'e'")
|
t.Fatalf("expected typing mode after 'e'")
|
||||||
}
|
}
|
||||||
for _, r := range "do it manually" {
|
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 {
|
if s := m.session("s1"); s == nil || s.Propose != nil {
|
||||||
t.Fatalf("expected proposal cleared after custom answer")
|
t.Fatalf("expected proposal cleared after custom answer")
|
||||||
@@ -109,7 +108,7 @@ func TestProposeCustomAnswerContinuesChat(t *testing.T) {
|
|||||||
|
|
||||||
func TestProposeEscDismisses(t *testing.T) {
|
func TestProposeEscDismisses(t *testing.T) {
|
||||||
m, _ := proposeModel()
|
m, _ := proposeModel()
|
||||||
m = applyProposeKey(m, tea.KeyMsg{Type: tea.KeyEsc})
|
m = applyProposeKey(m, keyMsg{Type: keyEsc})
|
||||||
if m.displayState() != StateInSession {
|
if m.displayState() != StateInSession {
|
||||||
t.Fatalf("expected StateInSession after esc, got %v", m.displayState())
|
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)
|
updated, _ := m.handleProposeKey(k)
|
||||||
return updated.(Model)
|
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
|
||||||
|
}
|
||||||
@@ -22,8 +22,11 @@ func containsFold(haystack, needle string) bool {
|
|||||||
return strings.Contains(strings.ToLower(haystack), strings.ToLower(needle))
|
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 {
|
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
|
// 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:
|
case protocol.TypeSessionResumed:
|
||||||
if s := m.session(msg.SessionID); s != nil {
|
if s := m.session(msg.SessionID); s != nil {
|
||||||
s.Status = "ACTIVE"
|
s.Status = "ACTIVE"
|
||||||
s.Pending = nil
|
s.clearApprovals()
|
||||||
s.LastEventAt = nowMillis()
|
s.LastEventAt = nowMillis()
|
||||||
}
|
}
|
||||||
case protocol.TypeSessionCompleted:
|
case protocol.TypeSessionCompleted:
|
||||||
@@ -134,6 +137,7 @@ func (m *Model) applyServer(msg protocol.ServerMessage) {
|
|||||||
s.Active = false
|
s.Active = false
|
||||||
s.Clar = nil
|
s.Clar = nil
|
||||||
s.Propose = nil
|
s.Propose = nil
|
||||||
|
s.clearApprovals()
|
||||||
}
|
}
|
||||||
case protocol.TypeSessionFailed:
|
case protocol.TypeSessionFailed:
|
||||||
m.touch(msg.SessionID, "FAILED")
|
m.touch(msg.SessionID, "FAILED")
|
||||||
@@ -141,6 +145,7 @@ func (m *Model) applyServer(msg protocol.ServerMessage) {
|
|||||||
s.Active = false
|
s.Active = false
|
||||||
s.Clar = nil
|
s.Clar = nil
|
||||||
s.Propose = nil
|
s.Propose = nil
|
||||||
|
s.clearApprovals()
|
||||||
}
|
}
|
||||||
case protocol.TypeStageStarted:
|
case protocol.TypeStageStarted:
|
||||||
if s := m.session(msg.SessionID); s != nil {
|
if s := m.session(msg.SessionID); s != nil {
|
||||||
@@ -196,6 +201,8 @@ func (m *Model) applyServer(msg protocol.ServerMessage) {
|
|||||||
}
|
}
|
||||||
s.LastEventAt = nowMillis()
|
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:
|
case protocol.TypeToolCompleted:
|
||||||
if s := m.session(msg.SessionID); s != nil {
|
if s := m.session(msg.SessionID); s != nil {
|
||||||
s.Active = false
|
s.Active = false
|
||||||
@@ -204,7 +211,13 @@ func (m *Model) applyServer(msg protocol.ServerMessage) {
|
|||||||
s.addEvent(msg.OccurredAt, "ToolCompleted", msg.ToolName)
|
s.addEvent(msg.OccurredAt, "ToolCompleted", msg.ToolName)
|
||||||
}
|
}
|
||||||
if msg.Diff != nil && *msg.Diff != "" {
|
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})
|
m.appendRouter(msg.SessionID, RouterEntry{Role: "tool", Content: *msg.Diff})
|
||||||
|
} else {
|
||||||
|
m.appendAction(msg.SessionID, "✓", actionToolText(msg.ToolName, msg.Summary))
|
||||||
}
|
}
|
||||||
case protocol.TypeToolAssessed:
|
case protocol.TypeToolAssessed:
|
||||||
if s := m.session(msg.SessionID); s != nil {
|
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.markTool(msg.ToolName, ToolFailed)
|
||||||
s.LastEventAt = msg.OccurredAt
|
s.LastEventAt = msg.OccurredAt
|
||||||
}
|
}
|
||||||
|
m.appendAction(msg.SessionID, "✗", actionToolText(msg.ToolName+" failed", msg.Reason))
|
||||||
case protocol.TypeToolRejected:
|
case protocol.TypeToolRejected:
|
||||||
if s := m.session(msg.SessionID); s != nil {
|
if s := m.session(msg.SessionID); s != nil {
|
||||||
s.markTool(msg.ToolName, ToolRejected)
|
s.markTool(msg.ToolName, ToolRejected)
|
||||||
s.LastEventAt = nowMillis()
|
s.LastEventAt = nowMillis()
|
||||||
}
|
}
|
||||||
|
m.appendAction(msg.SessionID, "✕", actionToolText(msg.ToolName+" blocked", msg.Reason))
|
||||||
case protocol.TypeArtifactCreated:
|
case protocol.TypeArtifactCreated:
|
||||||
if s := m.session(msg.SessionID); s != nil {
|
if s := m.session(msg.SessionID); s != nil {
|
||||||
s.addEvent(nowMillis(), "ArtifactCreated", msg.ArtifactID)
|
s.addEvent(nowMillis(), "ArtifactCreated", msg.ArtifactID)
|
||||||
@@ -252,13 +267,25 @@ func (m *Model) applyServer(msg protocol.ServerMessage) {
|
|||||||
m.onWorkflowProposed(msg)
|
m.onWorkflowProposed(msg)
|
||||||
case protocol.TypeApprovalResolved:
|
case protocol.TypeApprovalResolved:
|
||||||
if s := m.session(msg.SessionID); s != nil {
|
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
|
detail := msg.Outcome
|
||||||
if msg.Reason != "" {
|
if msg.Reason != "" {
|
||||||
detail += " — " + msg.Reason
|
detail += " — " + msg.Reason
|
||||||
}
|
}
|
||||||
s.addEvent(msg.OccurredAt, "ApprovalResolved", detail)
|
s.addEvent(msg.OccurredAt, "ApprovalResolved", detail)
|
||||||
s.LastEventAt = nowMillis()
|
s.LastEventAt = nowMillis()
|
||||||
|
m.appendAction(msg.SessionID, approvalIcon(msg.Outcome), approvalActionText(msg.Outcome, tool, msg.Reason))
|
||||||
}
|
}
|
||||||
case protocol.TypeSessionSnapshot:
|
case protocol.TypeSessionSnapshot:
|
||||||
m.onSnapshot(msg)
|
m.onSnapshot(msg)
|
||||||
@@ -325,6 +352,19 @@ func (m *Model) applyServer(msg protocol.ServerMessage) {
|
|||||||
case protocol.TypeHealthChecks:
|
case protocol.TypeHealthChecks:
|
||||||
m.health = msg.Health
|
m.health = msg.Health
|
||||||
m.healthLoading = false
|
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:
|
case protocol.TypeConfigSnapshot:
|
||||||
m.configFields = msg.ConfigFields
|
m.configFields = msg.ConfigFields
|
||||||
m.configRestart = msg.ConfigRestartRequired
|
m.configRestart = msg.ConfigRestartRequired
|
||||||
@@ -370,13 +410,81 @@ func sessionIDOf(msg protocol.ServerMessage) string {
|
|||||||
protocol.TypeWorkflowList, protocol.TypeRouterResponse,
|
protocol.TypeWorkflowList, protocol.TypeRouterResponse,
|
||||||
protocol.TypeModelChanged, protocol.TypeModelList, protocol.TypeResourceStatus,
|
protocol.TypeModelChanged, protocol.TypeModelList, protocol.TypeResourceStatus,
|
||||||
protocol.TypeArtifactList, protocol.TypeConfigSnapshot, protocol.TypeSessionStats,
|
protocol.TypeArtifactList, protocol.TypeConfigSnapshot, protocol.TypeSessionStats,
|
||||||
protocol.TypeIdeaList, protocol.TypeHealthChecks:
|
protocol.TypeIdeaList, protocol.TypeHealthChecks,
|
||||||
|
protocol.TypeFileList, protocol.TypeGrantList:
|
||||||
return ""
|
return ""
|
||||||
default:
|
default:
|
||||||
return msg.SessionID
|
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
|
// onSessionAnnounced fills in a session's workflow identity (the announce is the
|
||||||
// only event carrying workflowId) and applies auto-focus. The session entry itself
|
// only event carrying workflowId) and applies auto-focus. The session entry itself
|
||||||
// was already created by the auto-vivify path in applyServer.
|
// was already created by the auto-vivify path in applyServer.
|
||||||
@@ -414,7 +522,7 @@ func (m *Model) onApprovalRequired(msg protocol.ServerMessage) {
|
|||||||
Rationale: rationale,
|
Rationale: rationale,
|
||||||
}
|
}
|
||||||
if s := m.session(msg.SessionID); s != nil {
|
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) {
|
func (m *Model) onSnapshot(msg protocol.ServerMessage) {
|
||||||
var pending *Approval
|
var queue []*Approval
|
||||||
if len(msg.PendingAppr) > 0 {
|
for _, a := range msg.PendingAppr {
|
||||||
a := msg.PendingAppr[0]
|
queue = append(queue, &Approval{
|
||||||
pending = &Approval{
|
|
||||||
RequestID: a.RequestID, SessionID: msg.SessionID, Tier: a.Tier,
|
RequestID: a.RequestID, SessionID: msg.SessionID, Tier: a.Tier,
|
||||||
Risk: "unknown", ToolName: derefp(a.ToolName), Preview: derefp(a.Preview),
|
Risk: "unknown", ToolName: derefp(a.ToolName), Preview: derefp(a.Preview),
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
status := "running"
|
status := "running"
|
||||||
if msg.State != nil {
|
if msg.State != nil {
|
||||||
status = msg.State.Status
|
status = msg.State.Status
|
||||||
}
|
}
|
||||||
if pending != nil {
|
if len(queue) > 0 {
|
||||||
status = "PAUSED awaiting approval"
|
status = "PAUSED awaiting approval"
|
||||||
}
|
}
|
||||||
sess := Session{
|
sess := Session{
|
||||||
ID: msg.SessionID, Status: status, WorkflowID: msg.WorkflowID,
|
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 {
|
if msg.State != nil && msg.State.CurrentStageID != nil {
|
||||||
sess.CurrentStage = *msg.State.CurrentStageID
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,36 +1,40 @@
|
|||||||
package app
|
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:
|
// Palette is the "Soft" chrome from docs/visual/ref with the blue accent:
|
||||||
// rounded borders, opaque dark panels, generous spacing.
|
// rounded borders, opaque dark panels, generous spacing.
|
||||||
type Palette struct {
|
type Palette struct {
|
||||||
Bg lipgloss.Color // panel/background fill
|
Bg color.Color // panel/background fill
|
||||||
BgDeep lipgloss.Color // outermost backdrop (slightly darker)
|
BgDeep color.Color // outermost backdrop (slightly darker)
|
||||||
BgPanel lipgloss.Color // inside-panel fill
|
BgPanel color.Color // inside-panel fill
|
||||||
Sel lipgloss.Color // selection row background
|
Sel color.Color // selection row background
|
||||||
Border lipgloss.Color // idle panel border
|
Border color.Color // idle panel border
|
||||||
BorderHi lipgloss.Color // active panel border
|
BorderHi color.Color // active panel border
|
||||||
Fg lipgloss.Color // primary text
|
Fg color.Color // primary text
|
||||||
FgStrong lipgloss.Color // headings / emphasis
|
FgStrong color.Color // headings / emphasis
|
||||||
Dim lipgloss.Color // labels, secondary text
|
Dim color.Color // labels, secondary text
|
||||||
Faint lipgloss.Color // tertiary, hints
|
Faint color.Color // tertiary, hints
|
||||||
Accent lipgloss.Color // blue accent
|
Accent color.Color // blue accent
|
||||||
Accent2 lipgloss.Color // secondary accent
|
Accent2 color.Color // secondary accent
|
||||||
|
|
||||||
OK lipgloss.Color
|
OK color.Color
|
||||||
Warn lipgloss.Color
|
Warn color.Color
|
||||||
Bad lipgloss.Color
|
Bad color.Color
|
||||||
|
|
||||||
DiffAdd lipgloss.Color // added-line cell background
|
DiffAdd color.Color // added-line cell background
|
||||||
DiffDel lipgloss.Color // removed-line cell background
|
DiffDel color.Color // removed-line cell background
|
||||||
|
|
||||||
CatLifecycle lipgloss.Color
|
CatLifecycle color.Color
|
||||||
CatContext lipgloss.Color
|
CatContext color.Color
|
||||||
CatInference lipgloss.Color
|
CatInference color.Color
|
||||||
CatTool lipgloss.Color
|
CatTool color.Color
|
||||||
CatDomain lipgloss.Color
|
CatDomain color.Color
|
||||||
CatApproval lipgloss.Color
|
CatApproval color.Color
|
||||||
}
|
}
|
||||||
|
|
||||||
// SoftBlue mirrors the reference Soft theme (#14161a bg, #ced3da fg) with the
|
// 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.
|
// NewTheme builds the style set for a palette.
|
||||||
func NewTheme(p Palette) Theme {
|
func NewTheme(p Palette) Theme {
|
||||||
base := lipgloss.NewStyle().Background(p.Bg).Foreground(p.Fg)
|
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().
|
return lipgloss.NewStyle().
|
||||||
Border(lipgloss.RoundedBorder()).
|
Border(lipgloss.RoundedBorder()).
|
||||||
BorderForeground(border).
|
BorderForeground(border).
|
||||||
@@ -128,7 +132,7 @@ func NewTheme(p Palette) Theme {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// categoryColor maps an event category label to its accent color.
|
// 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 {
|
switch cat {
|
||||||
case "Lifecycle":
|
case "Lifecycle":
|
||||||
return t.P.CatLifecycle
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
+915
-137
File diff suppressed because it is too large
Load Diff
+435
-163
@@ -2,10 +2,13 @@ package app
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"image/color"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
tea "charm.land/bubbletea/v2"
|
||||||
|
"charm.land/lipgloss/v2"
|
||||||
"github.com/charmbracelet/x/ansi"
|
"github.com/charmbracelet/x/ansi"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -27,8 +30,63 @@ const (
|
|||||||
// and full-width chrome.
|
// and full-width chrome.
|
||||||
func (m Model) narrow() bool { return m.width < narrowWidth }
|
func (m Model) narrow() bool { return m.width < narrowWidth }
|
||||||
|
|
||||||
// View renders the whole frame purely from the model (immediate-mode).
|
// cursorMarker is a private-use rune the composer drops into the caret cell so
|
||||||
func (m Model) View() string {
|
// 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 {
|
if m.quitting {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
@@ -36,11 +94,15 @@ func (m Model) View() string {
|
|||||||
return m.theme.Screen.Render("correx — terminal too small")
|
return m.theme.Screen.Render("correx — terminal too small")
|
||||||
}
|
}
|
||||||
|
|
||||||
bottom := m.renderInput()
|
ds := m.displayState()
|
||||||
bottomH := inputH
|
bottom, bottomH := m.renderInput(), m.inputBarHeight()
|
||||||
if m.displayState() == StateApproval {
|
switch {
|
||||||
|
case ds == StateApproval:
|
||||||
bottomH = m.approvalBandHeight()
|
bottomH = m.approvalBandHeight()
|
||||||
bottom = m.renderApprovalBand(bottomH)
|
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
|
mainH := m.height - statusH - footerH - bottomH
|
||||||
@@ -48,12 +110,14 @@ func (m Model) View() string {
|
|||||||
mainH = 3
|
mainH = 3
|
||||||
}
|
}
|
||||||
|
|
||||||
base := lipgloss.JoinVertical(lipgloss.Left,
|
sections := []string{m.renderStatus(), m.renderMain(mainH)}
|
||||||
m.renderStatus(),
|
if bottomH > 0 {
|
||||||
m.renderMain(mainH),
|
sections = append(sections, bottom)
|
||||||
bottom,
|
}
|
||||||
m.renderFooter(),
|
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 {
|
if m.displayState() == StateClarification {
|
||||||
return m.center(m.clarificationModal())
|
return m.center(m.clarificationModal())
|
||||||
@@ -72,7 +136,7 @@ func (m Model) View() string {
|
|||||||
func (m Model) renderStatus() string {
|
func (m Model) renderStatus() string {
|
||||||
t := m.theme
|
t := m.theme
|
||||||
bg := t.P.BgPanel
|
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)
|
return lipgloss.NewStyle().Foreground(fg).Background(bg).Render(s)
|
||||||
}
|
}
|
||||||
nrw := m.narrow()
|
nrw := m.narrow()
|
||||||
@@ -93,17 +157,18 @@ func (m Model) renderStatus() string {
|
|||||||
|
|
||||||
if s := m.session(m.selectedID); s != nil && m.displayState() != StateIdle {
|
if s := m.session(m.selectedID); s != nil && m.displayState() != StateIdle {
|
||||||
parts = append(parts, span(s.Name, t.P.FgStrong))
|
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))
|
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)))
|
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))
|
parts = append(parts, span("⌂ "+shortPath(s.WorkspaceRoot), t.P.Faint))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if m.sbShow("model") {
|
||||||
model := m.currentModel
|
model := m.currentModel
|
||||||
if model == "" {
|
if model == "" {
|
||||||
model = "no 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))
|
parts = append(parts, span(model, t.P.Fg)+span(" ("+loc+")", t.P.Faint))
|
||||||
}
|
}
|
||||||
|
}
|
||||||
left := strings.Join(parts, sep)
|
left := strings.Join(parts, sep)
|
||||||
|
|
||||||
var right []string
|
var right []string
|
||||||
// Last-event clock (§2): always visible in-session, so a silent agent is never
|
// 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
|
// mistaken for a healthy one. Updates every frame tick; turns warn-colored when an
|
||||||
// active session has gone quiet past the stale threshold.
|
// 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
|
gap := nowMillis() - s.LastEventAt
|
||||||
clockFg := t.P.Faint
|
clockFg := t.P.Faint
|
||||||
if s.Active && gap > staleEventThresholdMs {
|
if s.Active && gap > staleEventThresholdMs {
|
||||||
@@ -135,7 +201,7 @@ func (m Model) renderStatus() string {
|
|||||||
}
|
}
|
||||||
right = append(right, span(label, clockFg))
|
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))
|
right = append(right, span(g, t.P.Faint))
|
||||||
}
|
}
|
||||||
if s := m.session(m.selectedID); s != nil && s.Active {
|
if s := m.session(m.selectedID); s != nil && s.Active {
|
||||||
@@ -221,6 +287,16 @@ func (m Model) renderFooter() string {
|
|||||||
switch {
|
switch {
|
||||||
case m.editMode == ModeInsert:
|
case m.editMode == ModeInsert:
|
||||||
hints = []string{hint("enter", "send"), hint("esc", "normal"), hint("↑↓", "history")}
|
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:
|
case m.displayState() == StateApproval:
|
||||||
// Approval actions live in the band itself; the footer offers navigation only.
|
// Approval actions live in the band itself; the footer offers navigation only.
|
||||||
hints = []string{hint("l", "back"), hint("e", "events"), hint("q", "quit")}
|
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")}
|
hints = []string{hint("↑↓", "choose"), hint("enter", "launch"), hint("e", "custom"), hint("esc", "later")}
|
||||||
case m.displayState() == StateIdle:
|
case m.displayState() == StateIdle:
|
||||||
if nrw {
|
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 {
|
} 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
|
default: // StateInSession
|
||||||
if nrw {
|
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 {
|
} 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 {
|
if s := m.session(m.selectedID); s != nil && s.Pending != nil {
|
||||||
hints = append(hints, hint("a", "approval (pending)"))
|
hints = append(hints, hint("a", "approval (pending)"))
|
||||||
@@ -262,39 +343,215 @@ func (m Model) renderFooter() string {
|
|||||||
// --- main split ---
|
// --- main split ---
|
||||||
|
|
||||||
func (m Model) renderMain(h int) string {
|
func (m Model) renderMain(h int) string {
|
||||||
|
if m.displayState() == StateIdle {
|
||||||
|
return m.renderLauncher(m.width, h)
|
||||||
|
}
|
||||||
if m.narrow() {
|
if m.narrow() {
|
||||||
return m.renderMainNarrow(h)
|
return m.renderMainNarrow(h)
|
||||||
}
|
}
|
||||||
leftW := m.width * 63 / 100
|
leftW := m.width * 63 / 100
|
||||||
rightW := m.width - leftW
|
rightW := m.width - leftW
|
||||||
|
|
||||||
var leftTitle, rightTitle string
|
// In-session / approval. The right panel cycles (d): events, changes (token usage +
|
||||||
var leftBody, rightBody []string
|
// changed files), or off (output takes the full width).
|
||||||
leftActive, rightActive := false, false
|
if m.rightPanel == 2 {
|
||||||
|
return m.theme.box("output", m.routerRows(m.width-4, h-2), m.width, h, true)
|
||||||
switch m.displayState() {
|
}
|
||||||
case StateIdle:
|
left := m.theme.box("output", m.routerRows(leftW-4, h-2), leftW, h, true)
|
||||||
leftTitle = "sessions"
|
var right string
|
||||||
if m.wfVisible {
|
if m.rightPanel == 1 {
|
||||||
leftTitle = "workflows"
|
right = m.theme.box("changes", m.changesRows(rightW-4, h), rightW, h, false)
|
||||||
leftBody = m.workflowRows(leftW - 4)
|
|
||||||
} else {
|
} else {
|
||||||
leftBody = m.sessionRows(leftW - 4)
|
right = m.theme.box("events", m.eventRows(rightW-4, h), rightW, h, false)
|
||||||
}
|
}
|
||||||
leftActive = true
|
return lipgloss.JoinHorizontal(lipgloss.Top, left, right)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
left := m.theme.box(leftTitle, leftBody, leftW, h, leftActive)
|
// changesRows is the in-session "changes" panel: a token-usage line plus a git-status-style
|
||||||
right := m.theme.box(rightTitle, rightBody, rightW, h, rightActive)
|
// list of files the session has written (summed +/- from each write's diff). ^x opens the
|
||||||
return lipgloss.JoinHorizontal(lipgloss.Top, left, right)
|
// 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
|
// 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).
|
// — the strip is too valuable to drop, the constraint is width not height).
|
||||||
func (m Model) renderMainNarrow(h int) string {
|
func (m Model) renderMainNarrow(h int) string {
|
||||||
w := m.width
|
w := m.width
|
||||||
if m.displayState() == StateIdle {
|
// Idle is handled by renderLauncher (which drops the rail when narrow); this only
|
||||||
title := "sessions"
|
// runs for in-session / approval: stack output over events.
|
||||||
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.
|
|
||||||
outH := h * 55 / 100
|
outH := h * 55 / 100
|
||||||
if outH < 3 {
|
if outH < 3 {
|
||||||
outH = 3
|
outH = 3
|
||||||
@@ -351,17 +601,17 @@ func (m Model) renderInput() string {
|
|||||||
|
|
||||||
insert := m.editMode == ModeInsert
|
insert := m.editMode == ModeInsert
|
||||||
caret := t.span(" ", t.P.Bg)
|
caret := t.span(" ", t.P.Bg)
|
||||||
if insert && m.caretVisible() {
|
if insert && m.overlay == OverlayNone {
|
||||||
caret = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Render("▏")
|
caret = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Render(cursorMarker)
|
||||||
}
|
}
|
||||||
var line string
|
var content []string
|
||||||
switch {
|
switch {
|
||||||
case m.inputBuffer == "" && !insert:
|
case m.inputBuffer == "" && !insert:
|
||||||
line = t.span(placeholder, t.P.Faint)
|
content = []string{t.span(placeholder, t.P.Faint)}
|
||||||
case m.inputBuffer == "":
|
case m.inputBuffer == "":
|
||||||
line = caret + t.span(placeholder, t.P.Faint)
|
content = []string{caret + t.span(placeholder, t.P.Faint)}
|
||||||
default:
|
default:
|
||||||
line = t.span(flattenForDisplay(m.inputBuffer), t.P.FgStrong) + caret
|
content = m.editorLines()
|
||||||
}
|
}
|
||||||
|
|
||||||
// status sub-line: <session/none> · <uuid> · <mode>
|
// status sub-line: <session/none> · <uuid> · <mode>
|
||||||
@@ -387,115 +637,104 @@ func (m Model) renderInput() string {
|
|||||||
sub += t.span(" · ", t.P.Faint) +
|
sub += t.span(" · ", t.P.Faint) +
|
||||||
t.span(mode, t.P.Dim)
|
t.span(mode, t.P.Dim)
|
||||||
|
|
||||||
body := []string{line, sub}
|
body := append(content, sub)
|
||||||
return t.box("", body, m.width, inputH, insert)
|
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 ---
|
// --- body row builders ---
|
||||||
|
|
||||||
func (m Model) sessionRows(w int) []string {
|
// shortDateTime formats a millis-since-epoch instant as a compact local timestamp for the
|
||||||
t := m.theme
|
// session list: "Jan 02 15:04" within the current year, "Jan 02 2006" for older years.
|
||||||
list := m.filteredSessions()
|
// Empty for a zero/absent time (renders as no date rather than a bogus epoch).
|
||||||
if len(list) == 0 {
|
func shortDateTime(ms int64) string {
|
||||||
return []string{t.span("no sessions yet", t.P.Faint), "", t.span("type a name below to begin", t.P.Faint)}
|
if ms <= 0 {
|
||||||
|
return ""
|
||||||
}
|
}
|
||||||
rows := make([]string, 0, len(list))
|
ts := time.UnixMilli(ms)
|
||||||
for _, s := range list {
|
if ts.Year() != time.Now().Year() {
|
||||||
sel := s.ID == m.selectedID && !m.wfVisible
|
return ts.Format("Jan 02 2006")
|
||||||
short := s.ID
|
|
||||||
if len(short) > 6 {
|
|
||||||
short = short[:6]
|
|
||||||
}
|
}
|
||||||
nameFg := t.P.Fg
|
return ts.Format("Jan 02 15:04")
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) routerRows(w, h int) []string {
|
func (m Model) routerRows(w, h int) []string {
|
||||||
t := m.theme
|
t := m.theme
|
||||||
msgs := m.routerMessages[m.selectedID]
|
if len(m.routerMessages[m.selectedID]) == 0 {
|
||||||
if len(msgs) == 0 {
|
|
||||||
return []string{t.span("awaiting router response…", t.P.Faint)}
|
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
|
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 {
|
switch e.Role {
|
||||||
case "user":
|
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("›")
|
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))
|
rows = append(rows, tag+" "+t.span(e.Content, t.P.FgStrong))
|
||||||
case "router":
|
case "router":
|
||||||
for _, ln := range wrap(e.Content, w) {
|
// The router turn is model output: render it as markdown (bold,
|
||||||
rows = append(rows, t.span(ln, t.P.Fg))
|
// 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 != "" {
|
if s := metricsSuffix(e.Metrics); s != "" {
|
||||||
rows = append(rows, t.span(s, t.P.Faint))
|
rows = append(rows, t.span(s, t.P.Faint))
|
||||||
}
|
}
|
||||||
case "tool":
|
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":
|
case "narration_llm":
|
||||||
diamond := lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Bold(true).Render("◆")
|
diamond := lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Bold(true).Render("◆")
|
||||||
lines := wrap(e.Content, w-2)
|
lines := wrap(e.Content, w-2)
|
||||||
@@ -511,24 +750,23 @@ func (m Model) routerRows(w, h int) []string {
|
|||||||
}
|
}
|
||||||
case "narration", "stage":
|
case "narration", "stage":
|
||||||
rows = append(rows, t.span(e.Content, t.P.Dim))
|
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
|
return rows, msgStart
|
||||||
if len(rows) > h {
|
|
||||||
rows = rows[len(rows)-h:]
|
|
||||||
}
|
|
||||||
return rows
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) eventRows(w, h int) []string {
|
func (m Model) eventRows(w, h int) []string {
|
||||||
t := m.theme
|
t := m.theme
|
||||||
header := lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Bold(true).Render("event stream") +
|
header := lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Bold(true).Render("event stream") +
|
||||||
t.span(" ", t.P.Bg)
|
t.span(" ", t.P.Bg) + m.eventStreamBadge()
|
||||||
if m.connected {
|
|
||||||
header += lipgloss.NewStyle().Foreground(t.P.OK).Background(t.P.Bg).Render("● live")
|
|
||||||
} else {
|
|
||||||
header += t.span("○ idle", t.P.Faint)
|
|
||||||
}
|
|
||||||
|
|
||||||
s := m.session(m.selectedID)
|
s := m.session(m.selectedID)
|
||||||
if s == nil || len(s.Events) == 0 {
|
if s == nil || len(s.Events) == 0 {
|
||||||
@@ -550,6 +788,30 @@ func (m Model) eventRows(w, h int) []string {
|
|||||||
return append([]string{header, ""}, evRows...)
|
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 ---
|
// --- width helpers ---
|
||||||
|
|
||||||
// shortPath abbreviates the user's home dir to ~ for a compact cwd in the status bar.
|
// 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.
|
// 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)
|
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
|
// 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
|
// 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.
|
// 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
|
avail := w - 2 // leading + trailing space
|
||||||
lw := lipgloss.Width(left)
|
lw := lipgloss.Width(left)
|
||||||
rw := lipgloss.Width(right)
|
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
|
// padTo pads (or truncates) a possibly-styled string to visible width w, filling
|
||||||
// with the given background so the panel stays opaque.
|
// 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)
|
vw := lipgloss.Width(s)
|
||||||
if vw == w {
|
if vw == w {
|
||||||
return s
|
return s
|
||||||
@@ -622,7 +884,7 @@ func padRaw(s string, w int) string {
|
|||||||
return s + strings.Repeat(" ", w-n)
|
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)
|
u := strings.ToUpper(status)
|
||||||
switch {
|
switch {
|
||||||
case strings.Contains(u, "FAIL"):
|
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.
|
// 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)
|
return lipgloss.NewStyle().Foreground(fg).Background(t.P.Bg).Render(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -739,7 +1001,17 @@ func itoa(n int) string {
|
|||||||
return string(b[i:])
|
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.
|
// metricsSuffix formats the faint latency+token annotation for a ROUTER turn.
|
||||||
func metricsSuffix(mtr *TurnMetrics) string {
|
func metricsSuffix(mtr *TurnMetrics) string {
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ const (
|
|||||||
TypeSessionStats = "session.stats"
|
TypeSessionStats = "session.stats"
|
||||||
TypeIdeaList = "idea.list"
|
TypeIdeaList = "idea.list"
|
||||||
TypeHealthChecks = "health.checks"
|
TypeHealthChecks = "health.checks"
|
||||||
|
TypeFileList = "file.list"
|
||||||
|
TypeGrantList = "grant.list"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ServerMessage is a flat decode of every server->client variant. Field names
|
// 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
|
// idea.list — the cross-session idea board
|
||||||
Ideas []IdeaDto `json:"ideas"`
|
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.
|
// 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})
|
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).
|
// GetSessionStats asks the server for a session's derived metrics (replied to with session.stats).
|
||||||
func GetSessionStats(sessionID string) []byte {
|
func GetSessionStats(sessionID string) []byte {
|
||||||
return encode("GetSessionStats", map[string]any{"sessionId": sessionID})
|
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.
|
// 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 {
|
func CreateGrant(sessionID, scope string, permittedTiers []string, reason, toolName string) []byte {
|
||||||
return encode("CreateGrant", map[string]any{
|
return encode("CreateGrant", map[string]any{
|
||||||
"sessionId": sessionID,
|
"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
|
// 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.
|
// working directory so the server can bind a workspace before any session starts.
|
||||||
func Hello(workingDir string) []byte {
|
func Hello(workingDir string) []byte {
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ type Status struct {
|
|||||||
// goroutine. Incoming and Conn are drained by the UI.
|
// goroutine. Incoming and Conn are drained by the UI.
|
||||||
type Client struct {
|
type Client struct {
|
||||||
url string
|
url string
|
||||||
|
httpBase string
|
||||||
send chan []byte
|
send chan []byte
|
||||||
in chan protocol.ServerMessage
|
in chan protocol.ServerMessage
|
||||||
conn chan Status
|
conn chan Status
|
||||||
@@ -36,15 +37,27 @@ type Client struct {
|
|||||||
|
|
||||||
// New builds a client targeting ws://host:port/stream.
|
// New builds a client targeting ws://host:port/stream.
|
||||||
func New(host string, port int) *Client {
|
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{
|
return &Client{
|
||||||
url: u.String(),
|
url: u.String(),
|
||||||
|
httpBase: (&url.URL{Scheme: "http", Host: hp}).String(),
|
||||||
send: make(chan []byte, 64),
|
send: make(chan []byte, 64),
|
||||||
in: make(chan protocol.ServerMessage, 256),
|
in: make(chan protocol.ServerMessage, 256),
|
||||||
conn: make(chan Status, 16),
|
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 {
|
func hostPort(host string, port int) string {
|
||||||
h := host
|
h := host
|
||||||
if h == "" {
|
if h == "" {
|
||||||
|
|||||||
+3
-2
@@ -8,7 +8,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "charm.land/bubbletea/v2"
|
||||||
"github.com/correx/tui-go/internal/app"
|
"github.com/correx/tui-go/internal/app"
|
||||||
"github.com/correx/tui-go/internal/ws"
|
"github.com/correx/tui-go/internal/ws"
|
||||||
)
|
)
|
||||||
@@ -33,7 +33,8 @@ func main() {
|
|||||||
defer cancel()
|
defer cancel()
|
||||||
go client.Run(ctx)
|
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 {
|
if _, err := p.Run(); err != nil {
|
||||||
fmt.Fprintln(os.Stderr, "correx tui error:", err)
|
fmt.Fprintln(os.Stderr, "correx tui error:", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user