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 @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()
@@ -464,6 +464,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.
@@ -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) } .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.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.ConfigFieldDto
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.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
@@ -14,11 +15,13 @@ 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 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
@@ -48,6 +51,25 @@ class StreamQueries(private val module: ServerModule) {
return ServerMessage.SessionStats(sessionId = sessionId, stats = report) 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), * 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 —
@@ -275,6 +275,24 @@ class ServerMessageSerializationTest {
assertEquals("i1", (decoded as ClientMessage.PromoteIdea).ideaId) 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,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))
}
}