feat(server): ListFiles/FileList protocol for the TUI @ file picker

New WS query: ClientMessage.ListFiles{sessionId} -> ServerMessage.FileList{paths}
(@SerialName "file.list"). StreamQueries.listFiles resolves the session's bound
workspace root (latest SessionWorkspaceBoundEvent) and lists it via WorkspaceFiles —
a path-only walk that reuses RepoMapIndexer's directory-ignore convention but, unlike
the indexer, never reads file contents and includes every file type (configs/assets,
not just source+markdown), sorted and capped. Tests: WorkspaceFiles (types/ignore/cap/
missing) + file.list wire round-trip. Go @ autocomplete consumes this next.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-21 10:19:21 +00:00
parent ba7ac06c16
commit 65e5d88ed7
7 changed files with 159 additions and 0 deletions
@@ -44,6 +44,10 @@ sealed class ClientMessage {
@Serializable
data class ListArtifacts(val sessionId: SessionId) : ClientMessage()
/** Operator request for the session workspace's file paths (the TUI `@` file-ref picker). */
@Serializable
data class ListFiles(val sessionId: SessionId) : ClientMessage()
/** Operator request for a session's derived metrics (replied to with a SessionStats). */
@Serializable
data class GetSessionStats(val sessionId: SessionId) : ClientMessage()
@@ -464,6 +464,19 @@ sealed interface ServerMessage {
override val sessionSequence: Long? = null,
) : ServerMessage, NonEventMessage
/**
* The session workspace's file paths (reply to ListFiles), for the TUI `@` file-ref picker.
* A snapshot directory walk, not event-derived, so cursors are null.
*/
@Serializable
@SerialName("file.list")
data class FileList(
val sessionId: SessionId,
val paths: List<String>,
override val sequence: Long? = null,
override val sessionSequence: Long? = null,
) : ServerMessage, NonEventMessage
/**
* The cross-session idea board (reply to ListIdeas / DiscardIdea). Not event-derived (a
* snapshot read folded from the whole event log), so cursors are null.
@@ -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)
}
}
@@ -235,6 +235,7 @@ class GlobalStreamHandler(private val module: ServerModule) {
.onFailure { log.error("cancel failed for session={}: {}", msg.sessionId.value, it.message, it) }
}
is ClientMessage.ListArtifacts -> sendFrame(queries.listArtifacts(msg.sessionId))
is ClientMessage.ListFiles -> sendFrame(queries.listFiles(msg.sessionId))
is ClientMessage.ListIdeas -> sendFrame(queries.listIdeas())
is ClientMessage.DiscardIdea -> handleDiscardIdea(msg, sendFrame)
is ClientMessage.PromoteIdea -> handlePromoteIdea(msg, sendFrame)
@@ -7,6 +7,7 @@ import com.correx.apps.server.protocol.ArtifactSummaryDto
import com.correx.apps.server.protocol.ConfigFieldDto
import com.correx.apps.server.protocol.IdeaDto
import com.correx.apps.server.protocol.ServerMessage
import com.correx.apps.server.workspace.WorkspaceFiles
import com.correx.core.router.IdeaReader
import com.correx.core.config.EditableConfig
import com.correx.core.events.events.ArtifactContentStoredEvent
@@ -14,11 +15,13 @@ import com.correx.core.events.events.ArtifactCreatedEvent
import com.correx.core.events.events.ArtifactValidatedEvent
import com.correx.core.events.events.ArtifactValidatingEvent
import com.correx.core.events.events.InferenceCompletedEvent
import com.correx.core.events.events.SessionWorkspaceBoundEvent
import com.correx.core.events.events.StoredEvent
import com.correx.core.events.types.ArtifactId
import com.correx.core.events.types.SessionId
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.nio.file.Paths
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.jsonObject
@@ -48,6 +51,25 @@ class StreamQueries(private val module: ServerModule) {
return ServerMessage.SessionStats(sessionId = sessionId, stats = 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
/**
* 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 —
@@ -275,6 +275,24 @@ class ServerMessageSerializationTest {
assertEquals("i1", (decoded as ClientMessage.PromoteIdea).ideaId)
}
@Test
fun `FileList encodes type file_list and paths`() {
val msg = ServerMessage.FileList(sessionId = SessionId("s1"), paths = listOf("README.md", "src/Main.kt"))
val jsonStr = ProtocolSerializer.encodeServerMessage(msg)
assert(jsonStr.contains("\"type\":\"file.list\"")) { "expected type=file.list" }
val decoded = json.decodeFromString<ServerMessage.FileList>(jsonStr)
assertEquals(listOf("README.md", "src/Main.kt"), decoded.paths)
}
@Test
fun `ListFiles decodes from the client wire format`() {
val wire = """{"type":"com.correx.apps.server.protocol.ClientMessage.ListFiles","sessionId":"s1"}"""
val decoded = ProtocolSerializer.decodeClientMessage(wire)
assert(decoded is ClientMessage.ListFiles) { "expected ListFiles, got $decoded" }
assertEquals("s1", (decoded as ClientMessage.ListFiles).sessionId.value)
}
@Test
fun `ClarificationResponse decodes from the client wire format`() {
val wire = """
@@ -0,0 +1,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))
}
}