feat(tasks): task search across REST, tool, and TUI

Ranked exact/substring search: TaskSearch (all terms AND, ranked title >
key > goal > criteria > notes) over one project or the whole board via
TaskService.search. Surfaced as GET /tasks?q=, a read-only task_search tool
for agents, and a `/` filter in the TUI board.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-23 22:08:11 +00:00
parent 1d7fab4ee4
commit 99f781687f
11 changed files with 357 additions and 28 deletions
@@ -8,6 +8,7 @@ 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.TaskSearch
import com.correx.core.tasks.TaskService
import com.correx.core.tasks.TaskStatus
import com.correx.core.tasks.TaskTargetKinds
@@ -83,8 +84,8 @@ data class NoteRequest(val body: String, val author: String? = null)
* 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
* an optional `project` (cross-project board without it); create requires `project` in the body.
* [GET /tasks/{id}/context] serves the agent bundle.
* 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)
@@ -113,11 +114,16 @@ private fun Route.taskCollectionRoutes(service: TaskService) {
}
// project is optional: scoped to one project when given, else the whole cross-project board.
val project = call.request.queryParameters["project"]
val tasks = (if (project != null) service.list(ProjectId(project)) else service.listAll())
val base = (if (project != null) service.list(ProjectId(project)) else service.listAll())
.filter { statusFilter == null || it.state.status == statusFilter }
.sortedByDescending { it.state.updatedAt } // most recently touched first
.map { it.toResponse() }
call.respond(tasks)
// 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)
}
call.respond(ordered.map { it.toResponse() })
}
post {
val body = call.receive<CreateTaskRequest>()
@@ -130,8 +130,10 @@ class TaskRoutesTest {
assertEquals("IN_PROGRESS", claimed.json()["status"]!!.jsonPrimitive.content)
assertEquals("claude-opus", claimed.json()["claimant"]!!.jsonPrimitive.content)
assertEquals("IN_REVIEW", client.post("/tasks/demo-1/submit-for-review").json()["status"]!!.jsonPrimitive.content)
assertEquals("DONE", client.post("/tasks/demo-1/complete").json()["status"]!!.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)
}
}
@@ -185,6 +187,25 @@ class TaskRoutesTest {
}
}
@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 `context bundle is served for a known task`(@TempDir tempDir: Path) {
testApplication {
+2
View File
@@ -326,6 +326,8 @@ type Model struct {
taskListErr string
taskDetail bool
taskDetailScroll int
taskFilter string // `/` substring narrow over id/title/goal
taskFilterTyping bool
// command palette
paletteFilter string
+88 -20
View File
@@ -85,10 +85,28 @@ func (m *Model) openTasks() tea.Cmd {
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) {
@@ -107,10 +125,11 @@ func (m *Model) applyTasksLoaded(msg tasksLoadedMsg) {
// selectedTask returns the highlighted task, or false when the list is empty / the
// cursor is out of range.
func (m Model) selectedTask() (TaskSummary, bool) {
if m.taskListIndex < 0 || m.taskListIndex >= len(m.taskList) {
f := m.filteredTasks()
if m.taskListIndex < 0 || m.taskListIndex >= len(f) {
return TaskSummary{}, false
}
return m.taskList[m.taskListIndex], true
return f[m.taskListIndex], true
}
// handleTasksKey owns every key while the board is open. In list mode: navigate, r
@@ -136,15 +155,36 @@ func (m Model) handleTasksKey(k keyMsg) (tea.Model, tea.Cmd) {
}
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.taskList)-1 {
if m.taskListIndex < len(m.filteredTasks())-1 {
m.taskListIndex++
}
case runeIs(k, "r"):
@@ -195,11 +235,12 @@ func (m Model) tasksModal() string {
}
t := m.theme
w := m.modalWidth()
tasks := m.filteredTasks()
var b strings.Builder
b.WriteString(m.titleLine("tasks"))
if len(m.taskList) > 0 {
b.WriteString(mbg(t, " ("+itoa(m.taskListIndex+1)+"/"+itoa(len(m.taskList))+")", t.P.Faint))
if len(tasks) > 0 {
b.WriteString(mbg(t, " ("+itoa(m.taskListIndex+1)+"/"+itoa(len(tasks))+")", t.P.Faint))
}
b.WriteString("\n\n")
@@ -213,9 +254,19 @@ func (m Model) tasksModal() string {
b.WriteString("\n" + modalHints(t, [][2]string{{"T/esc", "close"}}))
return t.Overlay.Width(w).Render(b.String())
}
if len(m.taskList) == 0 {
b.WriteString(mbg(t, " no tasks", t.P.Faint) + "\n")
b.WriteString("\n" + modalHints(t, [][2]string{{"r", "refresh"}, {"T/esc", "close"}}))
// 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())
}
@@ -225,33 +276,50 @@ func (m Model) tasksModal() string {
bodyH = 4
}
off := 0
if len(m.taskList) > bodyH {
if len(tasks) > bodyH {
off = m.taskListIndex - bodyH/2
if off < 0 {
off = 0
}
if off > len(m.taskList)-bodyH {
off = len(m.taskList) - bodyH
if off > len(tasks)-bodyH {
off = len(tasks) - bodyH
}
}
end := off + bodyH
if end > len(m.taskList) {
end = len(m.taskList)
if end > len(tasks) {
end = len(tasks)
}
for i := off; i < end; i++ {
b.WriteString(m.taskListRow(i) + "\n")
b.WriteString(m.taskListRow(tasks, i) + "\n")
}
b.WriteString("\n" + modalHints(t, [][2]string{
{"↑↓", "select"}, {"enter", "detail"}, {"r", "refresh"}, {"T/esc", "close"},
}))
b.WriteString("\n" + modalHints(t, m.taskListHints()))
return t.Overlay.Width(w).Render(b.String())
}
// taskListRow renders one board line: marker, id, status, title, and claimant.
func (m Model) taskListRow(i int) 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
tk := m.taskList[i]
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
@@ -113,6 +113,41 @@ func TestTasksNavigationBoundsChecked(t *testing.T) {
}
}
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 TestTasksFetchErrorShownNoPanic(t *testing.T) {
m := tasksModel()
m.overlay = OverlayTasks