From cd8fa09d09872df8a3b3c7417f77e1c680966ccf Mon Sep 17 00:00:00 2001 From: Manuel Puche <229537713+puchedev@users.noreply.github.com> Date: Sat, 7 Nov 2026 21:09:18 -0500 Subject: [PATCH 01/10] Search: words in herdr-core's chats too Co-Authored-By: Claude Opus 5.5 --- .../com/makefunstuff/herdr/ui/search/SearchScreen.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/src/main/kotlin/com/makefunstuff/herdr/ui/search/SearchScreen.kt b/app/src/main/kotlin/com/makefunstuff/herdr/ui/search/SearchScreen.kt index 0e5c9424..66852d0b 100644 --- a/app/src/main/kotlin/com/makefunstuff/herdr/ui/search/SearchScreen.kt +++ b/app/src/main/kotlin/com/makefunstuff/herdr/ui/search/SearchScreen.kt @@ -118,8 +118,8 @@ fun SearchScreen(nav: NavController, machineId: String? = null, initial: String // Words in the chats: asked of the machines a moment after typing stops, each in its own time. val chatHits = remember { mutableStateMapOf>() } var looking by remember { mutableStateOf(0) } - val online = hosts.filter { it.online && it.snapshot != null && (scoped == null || it.machine.id == scoped) } - val chatKey = online.joinToString(",") { h -> h.machine.id + ":" + h.snapshot!!.agents.joinToString { it.paneId + it.agentSession?.value.orEmpty() } } + val online = hosts.filter { it.online && it.view != null && (scoped == null || it.machine.id == scoped) } + val chatKey = online.joinToString(",") { h -> h.machine.id + ":" + h.view!!.agents.joinToString { it.paneId + it.agentSession?.value.orEmpty() } } LaunchedEffect(query.trim(), chatKey) { chatHits.clear() val q = query.trim() @@ -128,13 +128,13 @@ fun SearchScreen(nav: NavController, machineId: String? = null, initial: String coroutineScope { online.map { h -> async { - val chats = h.snapshot!!.agents.mapNotNull { a -> a.agentSession?.let { ChatSearch.Chat(a.paneId, it) } } + val chats = h.view!!.agents.mapNotNull { a -> a.agentSession?.let { ChatSearch.Chat(a.paneId, it) } } val command = ChatSearch.command(q, chats) ?: return@async looking++ try { val session = container.hosts.session(h.machine.id) ?: return@async val out = runCatching { session.awaitConnection(10_000).run(command, timeoutSeconds = 15).stdout }.getOrNull() ?: return@async - val kinds = h.snapshot!!.agents.associate { it.paneId to (it.agentSession?.agent ?: it.agent.orEmpty()) } + val kinds = h.view!!.agents.associate { it.paneId to (it.agentSession?.agent ?: it.agent.orEmpty()) } chatHits[h.machine.id] = ChatSearch.parse(out, q, kinds) } finally { looking-- From 7235c8e0ba247fe1a119f5dbba5f4b843c430e15 Mon Sep 17 00:00:00 2001 From: Manuel Puche <229537713+puchedev@users.noreply.github.com> Date: Sat, 7 Nov 2026 21:09:18 -0500 Subject: [PATCH 02/10] Preview: search finds words in a herdr-core chat Co-Authored-By: Claude Opus 5.5 --- tools/ui-preview/src/main/kotlin/preview/Scenes.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tools/ui-preview/src/main/kotlin/preview/Scenes.kt b/tools/ui-preview/src/main/kotlin/preview/Scenes.kt index a3eb7596..8e0d38e5 100644 --- a/tools/ui-preview/src/main/kotlin/preview/Scenes.kt +++ b/tools/ui-preview/src/main/kotlin/preview/Scenes.kt @@ -597,6 +597,8 @@ fun scenes(): List = listOf( // Shepherdr's tasks are found too, and open as their agents' chats. Scene("search-v2", route = Routes.search(q = "export"), setup = { home(); v2() }, actions = { settle() }), Scene("search-v2-open", route = Routes.search(q = "export"), setup = { home(); v2() }, actions = { settle(); tapText("Rate-limit the export endpoint") }), + // A word only in a herdr-core agent's chat (its Claude Code's transcript, found from its process): "In chats" finds it too. + Scene("search-core", route = Routes.search(q = "sequential"), setup = { home(); shepherdr3() }, actions = { settle() }), Scene("search-needs", route = Routes.search(q = "jobs"), setup = { home() }, actions = { settle(); tapText("Needs you") }), // Start work from a sentence: as a board task on devbox; as an agent now (its folders and the agents From 4b814ad244c52c8040e2fc7904e3efe5f0a90387 Mon Sep 17 00:00:00 2001 From: Manuel Puche <229537713+puchedev@users.noreply.github.com> Date: Sat, 7 Nov 2026 21:09:18 -0500 Subject: [PATCH 03/10] Search: workspaces of herdr-core show in the results too Co-Authored-By: Claude Opus 5.5 --- .../makefunstuff/herdr/core/search/Search.kt | 4 ++-- .../com/makefunstuff/herdr/core/SearchTest.kt | 17 +++++++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/core/src/main/kotlin/com/makefunstuff/herdr/core/search/Search.kt b/core/src/main/kotlin/com/makefunstuff/herdr/core/search/Search.kt index fc3c5ee7..2ff59f4a 100644 --- a/core/src/main/kotlin/com/makefunstuff/herdr/core/search/Search.kt +++ b/core/src/main/kotlin/com/makefunstuff/herdr/core/search/Search.kt @@ -70,7 +70,7 @@ data class SearchResults( } /** - * One box for everything the app holds: the machines' agents and workspaces (their last snapshots), + * One box for everything the app holds: the machines' agents and workspaces (their last views, herdr-core's too), * the boards' tasks (as last read), and the machines. Every word of the query has to be somewhere in * an item; a word at the start of its name counts most. An empty query lists what is there, the * agents and tasks waiting for you first. @@ -102,7 +102,7 @@ object Search { .filter { words.isNotEmpty() || it.first.task.status.needsYou } val machineScores = if (words.isEmpty()) emptyList() else inScope.map(::MachineHit).score(words) { h -> listOf(h.host.machine.label, h.host.machine.target) } val workspaces = if (words.isEmpty()) emptyList() else inScope.flatMap { s -> - val snap = s.snapshot ?: return@flatMap emptyList() + val snap = s.view ?: return@flatMap emptyList() snap.workspaces.map { ws -> val folder = ws.worktree?.checkoutPath?.ifEmpty { null } ?: snap.panes.firstOrNull { it.workspaceId == ws.workspaceId }?.cwd WorkspaceHit(s.machine.id, s.machine.label, ws, folder, s.workspaceMeta[ws.workspaceId]?.get("branch")?.ifEmpty { null }) diff --git a/core/src/test/kotlin/com/makefunstuff/herdr/core/SearchTest.kt b/core/src/test/kotlin/com/makefunstuff/herdr/core/SearchTest.kt index a0f21f81..01d0ad95 100644 --- a/core/src/test/kotlin/com/makefunstuff/herdr/core/SearchTest.kt +++ b/core/src/test/kotlin/com/makefunstuff/herdr/core/SearchTest.kt @@ -15,6 +15,7 @@ import com.makefunstuff.herdr.core.board.WorkStatus import com.makefunstuff.herdr.core.board.WorkTask import com.makefunstuff.herdr.core.chat.ChatSearch import com.makefunstuff.herdr.core.home.HomeBoard +import com.makefunstuff.herdr.core.host.HerdrCoreState import com.makefunstuff.herdr.core.host.HostState import com.makefunstuff.herdr.core.host.HostStatus import com.makefunstuff.herdr.core.search.Search @@ -104,6 +105,22 @@ class SearchTest { assertEquals(listOf("ci"), Search.of("ci-run", listOf(devbox, ci), boards).machines.map { it.machineId }) } + @Test + fun findsWorkspacesOfHerdrCoreToo() { + val core = HerdrCoreState( + "0.1.14", "devbox", "/home/dev/.local/bin/herdr-core", + SessionSnapshot(workspaces = listOf(WorkspaceInfo("hc:w1", label = "docs-site")), panes = listOf(PaneInfo("hc:t1", workspaceId = "hc:w1", tabId = "hc:w1:t1", cwd = "/home/dev/docs-site"))), + ) + val both = devbox.copy(herdrCore = core) + val hit = Search.of("docs-site", listOf(both), boards).workspaces.single() + assertEquals("/home/dev/docs-site", hit.folder) + // Online for herdr-core alone: only its workspaces. + val alone = devbox.copy(snapshot = null, herdrCore = core) + assertEquals(listOf("docs-site"), Search.of("docs", listOf(alone), boards).workspaces.map { it.name }) + // Classic herdr's still show next to them. + assertEquals(listOf("herdr-android"), Search.of("herdr-android", listOf(both), boards).workspaces.map { it.name }) + } + @Test fun aChipShowsOnlyItsAgentsAndTasks() { val r = Search.of("jobs", listOf(devbox, ci), boards, SearchFilter.Working) From 1d7f876617b95a725ea1fe866dc69ecbb9eed9bf Mon Sep 17 00:00:00 2001 From: Manuel Puche <229537713+puchedev@users.noreply.github.com> Date: Sat, 7 Nov 2026 21:09:18 -0500 Subject: [PATCH 04/10] Search: no terminal button on herdr-core workspaces Search lists herdr-core's workspaces now, but only classic herdr opens a terminal from the phone, so the button failed there. The Spaces screen already left it out. Co-Authored-By: Claude Opus 5.5 --- .../com/makefunstuff/herdr/ui/search/SearchScreen.kt | 2 +- .../kotlin/com/makefunstuff/herdr/core/search/Search.kt | 4 ++++ .../test/kotlin/com/makefunstuff/herdr/core/SearchTest.kt | 7 ++++++- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/app/src/main/kotlin/com/makefunstuff/herdr/ui/search/SearchScreen.kt b/app/src/main/kotlin/com/makefunstuff/herdr/ui/search/SearchScreen.kt index 66852d0b..20209ea2 100644 --- a/app/src/main/kotlin/com/makefunstuff/herdr/ui/search/SearchScreen.kt +++ b/app/src/main/kotlin/com/makefunstuff/herdr/ui/search/SearchScreen.kt @@ -345,7 +345,7 @@ private fun WorkspaceRow(w: WorkspaceHit, showMachine: Boolean, opening: Boolean leading = { IconTile(if (w.workspace.worktree != null) HerdrIcons.FolderGit2 else HerdrIcons.Folder, size = 36.dp) }, trailing = { if (opening) Box(Modifier.size(48.dp), contentAlignment = Alignment.Center) { Spinner(size = 20.dp) } - else BarIcon(HerdrIcons.SquareTerminal, "Open a terminal in ${w.name}", onTerminal) + else if (w.opensTerminal) BarIcon(HerdrIcons.SquareTerminal, "Open a terminal in ${w.name}", onTerminal) }, onClickLabel = "Open its machine", onClick = onOpen, diff --git a/core/src/main/kotlin/com/makefunstuff/herdr/core/search/Search.kt b/core/src/main/kotlin/com/makefunstuff/herdr/core/search/Search.kt index 2ff59f4a..ef313587 100644 --- a/core/src/main/kotlin/com/makefunstuff/herdr/core/search/Search.kt +++ b/core/src/main/kotlin/com/makefunstuff/herdr/core/search/Search.kt @@ -4,6 +4,7 @@ import com.makefunstuff.herdr.core.api.AgentStatus import com.makefunstuff.herdr.core.api.WorkspaceInfo import com.makefunstuff.herdr.core.board.WorkStatus import com.makefunstuff.herdr.core.board.WorkTask +import com.makefunstuff.herdr.core.hc.HcMapping import com.makefunstuff.herdr.core.home.Home import com.makefunstuff.herdr.core.home.HomeAgent import com.makefunstuff.herdr.core.home.HomeBoard @@ -55,6 +56,9 @@ data class WorkspaceHit( val branch: String? = null, ) { val name: String get() = workspace.label.ifBlank { workspace.workspaceId } + + /** Whether the phone can open a terminal in it: only classic herdr opens one, herdr-core doesn't. */ + val opensTerminal: Boolean get() = !workspace.workspaceId.startsWith(HcMapping.PREFIX) } /** What one query found across the machines, best first in each kind. */ diff --git a/core/src/test/kotlin/com/makefunstuff/herdr/core/SearchTest.kt b/core/src/test/kotlin/com/makefunstuff/herdr/core/SearchTest.kt index 01d0ad95..99bebe1c 100644 --- a/core/src/test/kotlin/com/makefunstuff/herdr/core/SearchTest.kt +++ b/core/src/test/kotlin/com/makefunstuff/herdr/core/SearchTest.kt @@ -32,6 +32,7 @@ import java.nio.file.Files import java.util.concurrent.TimeUnit import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue @@ -114,11 +115,15 @@ class SearchTest { val both = devbox.copy(herdrCore = core) val hit = Search.of("docs-site", listOf(both), boards).workspaces.single() assertEquals("/home/dev/docs-site", hit.folder) + // herdr-core opens no terminal from the phone, so its row offers none. + assertFalse(hit.opensTerminal) // Online for herdr-core alone: only its workspaces. val alone = devbox.copy(snapshot = null, herdrCore = core) assertEquals(listOf("docs-site"), Search.of("docs", listOf(alone), boards).workspaces.map { it.name }) // Classic herdr's still show next to them. - assertEquals(listOf("herdr-android"), Search.of("herdr-android", listOf(both), boards).workspaces.map { it.name }) + val classic = Search.of("herdr-android", listOf(both), boards).workspaces + assertEquals(listOf("herdr-android"), classic.map { it.name }) + assertTrue(classic.single().opensTerminal) } @Test From d249a05868aaf87f4dc8aa4144255fc2c5992bd9 Mon Sep 17 00:00:00 2001 From: Manuel Puche <229537713+puchedev@users.noreply.github.com> Date: Sat, 7 Nov 2026 21:09:18 -0500 Subject: [PATCH 05/10] Search: the terminal action only for a workspace that opens one Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- .../kotlin/com/makefunstuff/herdr/ui/search/SearchScreen.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 57bee462..4e6e5338 100644 --- a/README.md +++ b/README.md @@ -102,7 +102,7 @@ Swipe an agent left for **Pin** (it stays on top, under Needs you, in the order The search icon on **Agents**, **Board** and a machine (or **Ctrl+K** with a hardware keyboard, from anywhere) opens one box for the agents, the boards' tasks, the machines, their workspaces and the words in the recent chats. Chips above the results filter them to what **Needs you**, what is **Working** and what is **Done**, each with its count. From a machine it searches only that machine, until its chip is taken off. Every word typed has to be in a result, and a word at the start of a name counts most; with nothing typed it lists the agents, the tasks waiting for you and **Start work…**. -Words in the chats are looked for on the machines themselves: each running agent's own transcript (Claude Code, codex, pi and omp) is searched where it is, a moment after you stop typing (three letters at the least), and what was said around the words shows with who said it and when; tool calls and their output don't count. Under the results, **New task about "…"** starts work about the words, and **Open a terminal in …** opens a shell in the workspace found (each workspace row has the same terminal button). Enter opens the first result, Esc goes back. +Words in the chats are looked for on the machines themselves: each running agent's own transcript (Claude Code, codex, pi and omp) is searched where it is, a moment after you stop typing (three letters at the least), and what was said around the words shows with who said it and when; tool calls and their output don't count. Under the results, **New task about "…"** starts work about the words, and **Open a terminal in …** opens a shell in the workspace found (each workspace row of classic herdr has the same terminal button; herdr-core's workspaces open no terminal from the phone). Enter opens the first result, Esc goes back. ## Start work diff --git a/app/src/main/kotlin/com/makefunstuff/herdr/ui/search/SearchScreen.kt b/app/src/main/kotlin/com/makefunstuff/herdr/ui/search/SearchScreen.kt index 20209ea2..9c6f2ec2 100644 --- a/app/src/main/kotlin/com/makefunstuff/herdr/ui/search/SearchScreen.kt +++ b/app/src/main/kotlin/com/makefunstuff/herdr/ui/search/SearchScreen.kt @@ -262,7 +262,7 @@ fun SearchScreen(nav: NavController, machineId: String? = null, initial: String modifier = Modifier.padding(horizontal = Space.screen + Space.xs, vertical = Space.l), ) } - val ws = results.workspaces.firstOrNull() + val ws = results.workspaces.firstOrNull { it.opensTerminal } actions( listOfNotNull( Action("New task about “${query.trim()}”…", "Add it to a board, or start an agent now", HerdrIcons.Plus, accent = true) { startWork(query) }, From 4d3b2559ace56e28d33750068bc5892a6614065f Mon Sep 17 00:00:00 2001 From: Manuel Puche <229537713+puchedev@users.noreply.github.com> Date: Sat, 7 Nov 2026 21:09:20 -0500 Subject: [PATCH 06/10] Chat on Windows: find Claude's session under the pane's shell Co-Authored-By: Claude Opus 5.5 --- .../herdr/core/chat/TranscriptFollower.kt | 21 ++++- .../herdr/core/TranscriptWindowsTest.kt | 86 +++++++++++++++++++ 2 files changed, 106 insertions(+), 1 deletion(-) create mode 100644 core/src/test/kotlin/com/makefunstuff/herdr/core/TranscriptWindowsTest.kt diff --git a/core/src/main/kotlin/com/makefunstuff/herdr/core/chat/TranscriptFollower.kt b/core/src/main/kotlin/com/makefunstuff/herdr/core/chat/TranscriptFollower.kt index 62503059..45000211 100644 --- a/core/src/main/kotlin/com/makefunstuff/herdr/core/chat/TranscriptFollower.kt +++ b/core/src/main/kotlin/com/makefunstuff/herdr/core/chat/TranscriptFollower.kt @@ -32,13 +32,32 @@ object TranscriptFollower { * agent's process, which reports no session) is looked up as Shepherdr 3.1 does: Claude Code * names its session in `sessions/.json`, codex keeps its rollout file open; the process's * children are tried too, for an agent started through a wrapper. + * On Windows (Git Bash) the children come from the process table, as there is no `pgrep`. */ internal val FIND = """ if [ "${'$'}kind" = path ]; then f="${'$'}value" case "${'$'}f" in "~/"*) f="${'$'}HOME/${'$'}{f#\~/}" ;; esac elif [ "${'$'}kind" = pid ]; then - kids() { for k in ${'$'}(pgrep -P "${'$'}1" 2>/dev/null); do echo "${'$'}k"; kids "${'$'}k"; done; } + case "${'$'}(uname -s 2>/dev/null)" in + MINGW*|MSYS*|CYGWIN*) + # Git Bash has no pgrep and can't see Windows processes. Read the table of every process and + # its parent once and walk it down from the pid; a parent id can be stale (Windows reuses + # pids), so each pid is taken once. + kids() { + powershell.exe -NoProfile -Command 'Get-CimInstance Win32_Process | Format-Table -HideTableHeaders ProcessId,ParentProcessId' 2>/dev/null /dev/null); do echo "${'$'}k"; kids "${'$'}k"; done; } ;; + esac for p in "${'$'}value" ${'$'}(kids "${'$'}value"); do case "${'$'}agent" in claude) diff --git a/core/src/test/kotlin/com/makefunstuff/herdr/core/TranscriptWindowsTest.kt b/core/src/test/kotlin/com/makefunstuff/herdr/core/TranscriptWindowsTest.kt new file mode 100644 index 00000000..3ebe6a08 --- /dev/null +++ b/core/src/test/kotlin/com/makefunstuff/herdr/core/TranscriptWindowsTest.kt @@ -0,0 +1,86 @@ +package com.makefunstuff.herdr.core + +import com.makefunstuff.herdr.core.api.AgentSessionRef +import com.makefunstuff.herdr.core.chat.TranscriptFollower +import java.io.File +import java.nio.file.Files +import java.util.concurrent.TimeUnit +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** Finding a transcript from a pane's pid on a Windows machine, where `pgrep` doesn't exist. */ +class TranscriptWindowsTest { + private val session = "3c2c5876-a786-4c23-8ac5-471296257bc4" + + /** + * Runs [TranscriptFollower.command] for the pane's pid 16744 with Git Bash's `uname` and a + * `powershell.exe` that prints [table]; [sessionPid] has the Claude session file, if any. + * Returns the first line it prints. + */ + private fun run(table: String, sessionPid: String?, crlf: Boolean = false): String { + val home = Files.createTempDirectory("transcript-win").toFile() + try { + val bin = File(home, "bin").apply { mkdirs() } + File(bin, "uname").apply { writeText("#!/bin/sh\necho MINGW64_NT-10.0-26100\n"); setExecutable(true) } + // The real one prints CRLF line endings, and reads nothing from its stdin. + val eol = if (crlf) "\r\n" else "\n" + val rows = table.trim().lines().joinToString("") { "$it$eol" } + File(bin, "powershell.exe").apply { writeText("#!/bin/sh\nprintf '$rows'\n"); setExecutable(true) } + File(home, ".claude/projects/C--Users-dev-proj").apply { mkdirs() }.resolve("$session.jsonl").writeText("{\"a\":1}\n") + File(home, ".claude/sessions").mkdirs() + if (sessionPid != null) File(home, ".claude/sessions/$sessionPid.json").writeText("""{"pid":$sessionPid,"sessionId":"$session"}""") + val ref = AgentSessionRef(kind = "pid", value = "16744", agent = "claude") + // Written to a file and run with `sh `: on Windows, Java breaks the quotes of `sh -c`. + val command = File(home, "follow.sh").apply { writeText(TranscriptFollower.command(ref, 100) + "\n") } + val p = ProcessBuilder("sh", command.path) + .apply { environment().apply { put("HOME", home.path); put("PATH", "${bin.path}:/usr/bin:/bin"); remove("CLAUDE_CONFIG_DIR") } } + .redirectErrorStream(true) + .start() + // Closing stdin is how the app ends the stream. + p.outputStream.close() + val first = p.inputStream.bufferedReader().readLine() ?: "" + if (!p.waitFor(10, TimeUnit.SECONDS)) p.destroyForcibly() + return first + } finally { + home.deleteRecursively() + } + } + + private fun assertFound(first: String) { + assertTrue(first.startsWith("@@file ") && first.endsWith("/$session.jsonl"), first) + } + + @Test + fun findsClaudeUnderTheWindowsShell() { + assertFound(run("16744 900\n27492 16744\n31000 27492\n", sessionPid = "27492")) + } + + @Test + fun findsAGrandchild() { + assertFound(run("16744 900\n27492 16744\n31000 27492\n", sessionPid = "31000")) + } + + @Test + fun readsPowershellsCrlfTable() { + assertFound(run("16744 900\n27492 16744\n", sessionPid = "27492", crlf = true)) + } + + @Test + fun missingWithoutASessionFile() { + assertEquals("@@missing", run("16744 900\n27492 16744\n31000 27492\n", sessionPid = null)) + } + + @Test + fun ignoresProcessesOutsideThePanesTree() { + // 27492 belongs to another shell, so its session isn't this pane's. + assertEquals("@@missing", run("16744 900\n27492 5000\n", sessionPid = "27492")) + } + + @Test + fun aStaleParentIdCannotLoopTheWalk() { + // Windows reuses pids: 16744's own parent id points back down at its child. + assertFound(run("16744 31000\n27492 16744\n31000 27492\n40000 31000\n", sessionPid = "40000")) + assertEquals("@@missing", run("16744 16744\n27492 31000\n31000 27492\n", sessionPid = null)) + } +} From b31b7d91147f48d9f51526b965b77427fc831fa0 Mon Sep 17 00:00:00 2001 From: Manuel Puche <229537713+puchedev@users.noreply.github.com> Date: Sat, 7 Nov 2026 21:09:20 -0500 Subject: [PATCH 07/10] Chat search on Windows: words found though Git Bash's grep can't do -i with -F Co-Authored-By: Claude Opus 5.5 --- .../herdr/core/chat/ChatSearch.kt | 3 +- .../herdr/core/TranscriptWindowsTest.kt | 84 +++++++++++++++---- 2 files changed, 69 insertions(+), 18 deletions(-) diff --git a/core/src/main/kotlin/com/makefunstuff/herdr/core/chat/ChatSearch.kt b/core/src/main/kotlin/com/makefunstuff/herdr/core/chat/ChatSearch.kt index 5d9ff6b2..91c7af12 100644 --- a/core/src/main/kotlin/com/makefunstuff/herdr/core/chat/ChatSearch.kt +++ b/core/src/main/kotlin/com/makefunstuff/herdr/core/chat/ChatSearch.kt @@ -25,6 +25,7 @@ object ChatSearch { const val MIN_QUERY = 3 // $1 the word looked for, then pane, kind, value and agent for each chat. + // Git Bash's grep aborts on -i with -F, so when grep fails (status above 1) awk matches the lines instead. private val SCRIPT = """ q="${'$'}1"; shift while [ ${'$'}# -ge 4 ]; do @@ -32,7 +33,7 @@ object ChatSearch { """.trimIndent() + "\n" + TranscriptFollower.FIND + "\n" + """ if [ -n "${'$'}f" ] && [ -f "${'$'}f" ]; then echo "$MARK${'$'}pane" - grep -i -F -- "${'$'}q" "${'$'}f" 2>/dev/null | awk 'length(${'$'}0) < $MAX_LINE' | tail -n $LINES + { grep -i -F -- "${'$'}q" "${'$'}f" 2>/dev/null; [ ${'$'}? -le 1 ] || Q="${'$'}q" awk 'BEGIN { q = tolower(ENVIRON["Q"]) } index(tolower(${'$'}0), q)' "${'$'}f"; } | awk 'length(${'$'}0) < $MAX_LINE' | tail -n $LINES fi done """.trimIndent() diff --git a/core/src/test/kotlin/com/makefunstuff/herdr/core/TranscriptWindowsTest.kt b/core/src/test/kotlin/com/makefunstuff/herdr/core/TranscriptWindowsTest.kt index 3ebe6a08..7f0bc60a 100644 --- a/core/src/test/kotlin/com/makefunstuff/herdr/core/TranscriptWindowsTest.kt +++ b/core/src/test/kotlin/com/makefunstuff/herdr/core/TranscriptWindowsTest.kt @@ -1,6 +1,7 @@ package com.makefunstuff.herdr.core import com.makefunstuff.herdr.core.api.AgentSessionRef +import com.makefunstuff.herdr.core.chat.ChatSearch import com.makefunstuff.herdr.core.chat.TranscriptFollower import java.io.File import java.nio.file.Files @@ -14,11 +15,11 @@ class TranscriptWindowsTest { private val session = "3c2c5876-a786-4c23-8ac5-471296257bc4" /** - * Runs [TranscriptFollower.command] for the pane's pid 16744 with Git Bash's `uname` and a - * `powershell.exe` that prints [table]; [sessionPid] has the Claude session file, if any. - * Returns the first line it prints. + * Sets up a Windows machine in a temp home: Git Bash's `uname`, a `powershell.exe` that prints + * [table], the transcript with [jsonl] in it, and [sessionPid]'s Claude session file, if any. Runs + * [block] with the home and its `bin` (first on the PATH), then removes the home. */ - private fun run(table: String, sessionPid: String?, crlf: Boolean = false): String { + private fun onWindows(table: String, sessionPid: String?, crlf: Boolean = false, jsonl: String = "{\"a\":1}", block: (File, File) -> T): T { val home = Files.createTempDirectory("transcript-win").toFile() try { val bin = File(home, "bin").apply { mkdirs() } @@ -27,26 +28,42 @@ class TranscriptWindowsTest { val eol = if (crlf) "\r\n" else "\n" val rows = table.trim().lines().joinToString("") { "$it$eol" } File(bin, "powershell.exe").apply { writeText("#!/bin/sh\nprintf '$rows'\n"); setExecutable(true) } - File(home, ".claude/projects/C--Users-dev-proj").apply { mkdirs() }.resolve("$session.jsonl").writeText("{\"a\":1}\n") + File(home, ".claude/projects/C--Users-dev-proj").apply { mkdirs() }.resolve("$session.jsonl").writeText(jsonl + "\n") File(home, ".claude/sessions").mkdirs() if (sessionPid != null) File(home, ".claude/sessions/$sessionPid.json").writeText("""{"pid":$sessionPid,"sessionId":"$session"}""") - val ref = AgentSessionRef(kind = "pid", value = "16744", agent = "claude") - // Written to a file and run with `sh `: on Windows, Java breaks the quotes of `sh -c`. - val command = File(home, "follow.sh").apply { writeText(TranscriptFollower.command(ref, 100) + "\n") } - val p = ProcessBuilder("sh", command.path) - .apply { environment().apply { put("HOME", home.path); put("PATH", "${bin.path}:/usr/bin:/bin"); remove("CLAUDE_CONFIG_DIR") } } - .redirectErrorStream(true) - .start() - // Closing stdin is how the app ends the stream. - p.outputStream.close() - val first = p.inputStream.bufferedReader().readLine() ?: "" - if (!p.waitFor(10, TimeUnit.SECONDS)) p.destroyForcibly() - return first + return block(home, bin) } finally { home.deleteRecursively() } } + /** Runs [command] with `sh`, as the app does over SSH, and returns what it prints, giving up after 10 seconds. */ + private fun sh(home: File, bin: File, command: String): String { + // Written to a file and run with `sh `: on Windows, Java breaks the quotes of `sh -c`. + val file = File(home, "run.sh").apply { writeText(command + "\n") } + val out = File(home, "run.out") + val p = ProcessBuilder("sh", file.path) + .apply { environment().apply { put("HOME", home.path); put("PATH", "${bin.path}:/usr/bin:/bin"); remove("CLAUDE_CONFIG_DIR") } } + .redirectErrorStream(true) + .redirectOutput(out) + .start() + // Closing stdin is how the app ends the stream. + p.outputStream.close() + if (!p.waitFor(10, TimeUnit.SECONDS)) p.destroyForcibly().waitFor() + return out.readText() + } + + /** + * Runs [TranscriptFollower.command] for the pane's pid 16744 on a Windows machine whose + * `powershell.exe` prints [table]; [sessionPid] has the Claude session file, if any. + * Returns the first line it prints. + */ + private fun run(table: String, sessionPid: String?, crlf: Boolean = false): String = + onWindows(table, sessionPid, crlf) { home, bin -> + val ref = AgentSessionRef(kind = "pid", value = "16744", agent = "claude") + sh(home, bin, TranscriptFollower.command(ref, 100)).lineSequence().firstOrNull() ?: "" + } + private fun assertFound(first: String) { assertTrue(first.startsWith("@@file ") && first.endsWith("/$session.jsonl"), first) } @@ -83,4 +100,37 @@ class TranscriptWindowsTest { assertFound(run("16744 31000\n27492 16744\n31000 27492\n40000 31000\n", sessionPid = "40000")) assertEquals("@@missing", run("16744 16744\n27492 31000\n31000 27492\n", sessionPid = null)) } + + private val said = """{"type":"user","message":{"role":"user","content":"The Flaky upload test"},"timestamp":"2026-11-07T10:00:00Z","uuid":"u1"}""" + + /** The chat search of word [query] on a Windows machine, with [setup] run on its `bin` first. */ + private fun search(query: String, setup: (File) -> Unit = {}): List = + onWindows("16744 900\n27492 16744\n", sessionPid = "27492", jsonl = said) { home, bin -> + setup(bin) + val chat = ChatSearch.Chat("p1", AgentSessionRef(kind = "pid", value = "16744", agent = "claude")) + val stdout = sh(home, bin, ChatSearch.command(query, listOf(chat))!!) + ChatSearch.parse(stdout, query, mapOf("p1" to "claude")) + } + + private fun assertFlaky(hits: List) { + assertEquals(listOf("p1"), hits.map { it.paneId }) + assertTrue("Flaky" in hits.single().snippet, hits.single().snippet) + } + + @Test + fun searchFindsWordsWhenGrepAborts() { + // Git Bash's grep aborts on -i with -F: it is killed and prints nothing. + assertFlaky(search("flaky") { bin -> File(bin, "grep").apply { writeText("#!/bin/sh\nkill -ABRT \$\$\n"); setExecutable(true) } }) + } + + @Test + fun searchUsesTheGrepOfTheMachine() { + // With a grep that works the words are found by it, and the fallback isn't needed. + assertFlaky(search("flaky")) + } + + @Test + fun searchWithoutAMatchStaysEmpty() { + assertEquals(emptyList(), search("missing")) + } } From 57e0dc9842cbdf206062bb617f38381fff915f12 Mon Sep 17 00:00:00 2001 From: Manuel Puche <229537713+puchedev@users.noreply.github.com> Date: Sat, 7 Nov 2026 21:09:20 -0500 Subject: [PATCH 08/10] Chat on Windows tests: a made-up session id Co-Authored-By: Claude Opus 5.5 --- .../makefunstuff/herdr/core/TranscriptWindowsTest.kt | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/core/src/test/kotlin/com/makefunstuff/herdr/core/TranscriptWindowsTest.kt b/core/src/test/kotlin/com/makefunstuff/herdr/core/TranscriptWindowsTest.kt index 7f0bc60a..e30aff7b 100644 --- a/core/src/test/kotlin/com/makefunstuff/herdr/core/TranscriptWindowsTest.kt +++ b/core/src/test/kotlin/com/makefunstuff/herdr/core/TranscriptWindowsTest.kt @@ -12,14 +12,20 @@ import kotlin.test.assertTrue /** Finding a transcript from a pane's pid on a Windows machine, where `pgrep` doesn't exist. */ class TranscriptWindowsTest { - private val session = "3c2c5876-a786-4c23-8ac5-471296257bc4" + private val session = "00000000-0000-4000-8000-000000000001" /** * Sets up a Windows machine in a temp home: Git Bash's `uname`, a `powershell.exe` that prints * [table], the transcript with [jsonl] in it, and [sessionPid]'s Claude session file, if any. Runs * [block] with the home and its `bin` (first on the PATH), then removes the home. */ - private fun onWindows(table: String, sessionPid: String?, crlf: Boolean = false, jsonl: String = "{\"a\":1}", block: (File, File) -> T): T { + private fun onWindows( + table: String, + sessionPid: String?, + crlf: Boolean = false, + jsonl: String = "{\"a\":1}", + block: (File, File) -> T, + ): T { val home = Files.createTempDirectory("transcript-win").toFile() try { val bin = File(home, "bin").apply { mkdirs() } From a8cd5d69a89229bb75e8eb5c98d48ebb86c2c4f2 Mon Sep 17 00:00:00 2001 From: Manuel Puche <229537713+puchedev@users.noreply.github.com> Date: Sat, 7 Nov 2026 21:09:20 -0500 Subject: [PATCH 09/10] Chat search on Windows: read the process table once per machine, not once per chat Co-Authored-By: Claude Opus 5.5 --- .../herdr/core/chat/TranscriptFollower.kt | 7 ++--- .../herdr/core/TranscriptWindowsTest.kt | 27 ++++++++++++++++++- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/core/src/main/kotlin/com/makefunstuff/herdr/core/chat/TranscriptFollower.kt b/core/src/main/kotlin/com/makefunstuff/herdr/core/chat/TranscriptFollower.kt index 45000211..c2d657ff 100644 --- a/core/src/main/kotlin/com/makefunstuff/herdr/core/chat/TranscriptFollower.kt +++ b/core/src/main/kotlin/com/makefunstuff/herdr/core/chat/TranscriptFollower.kt @@ -42,10 +42,11 @@ object TranscriptFollower { case "${'$'}(uname -s 2>/dev/null)" in MINGW*|MSYS*|CYGWIN*) # Git Bash has no pgrep and can't see Windows processes. Read the table of every process and - # its parent once and walk it down from the pid; a parent id can be stale (Windows reuses - # pids), so each pid is taken once. + # its parent (once for all the chats of a search: ptable stays set) and walk it down from the + # pid; a parent id can be stale (Windows reuses pids), so each pid is taken once. + [ -n "${'$'}ptable" ] || ptable=${'$'}(powershell.exe -NoProfile -Command 'Get-CimInstance Win32_Process | Format-Table -HideTableHeaders ProcessId,ParentProcessId' 2>/dev/null /dev/null > \"\$HOME/powershell.calls\"\nprintf '$rows'\n"); setExecutable(true) } File(home, ".claude/projects/C--Users-dev-proj").apply { mkdirs() }.resolve("$session.jsonl").writeText(jsonl + "\n") File(home, ".claude/sessions").mkdirs() if (sessionPid != null) File(home, ".claude/sessions/$sessionPid.json").writeText("""{"pid":$sessionPid,"sessionId":"$session"}""") @@ -135,6 +136,30 @@ class TranscriptWindowsTest { assertFlaky(search("flaky")) } + @Test + fun aSearchReadsTheProcessTableOnceForAllItsChats() { + // Three chats, each with its own pane pid: one powershell for the machine, not one per chat. + val table = "16744 900\n27492 16744\n17000 900\n17001 17000\n18000 900\n18001 18000\n" + onWindows(table, sessionPid = "27492", jsonl = said) { home, bin -> + for (pid in listOf("17001", "18001")) File(home, ".claude/sessions/$pid.json").writeText("""{"pid":$pid,"sessionId":"$session"}""") + val chats = listOf("16744" to "p1", "17000" to "p2", "18000" to "p3").map { (pid, pane) -> + ChatSearch.Chat(pane, AgentSessionRef(kind = "pid", value = pid, agent = "claude")) + } + val stdout = sh(home, bin, ChatSearch.command("flaky", chats)!!) + assertEquals(listOf("p1", "p2", "p3"), ChatSearch.parse(stdout, "flaky", chats.associate { it.paneId to "claude" }).map { it.paneId }) + assertEquals(1, File(home, "powershell.calls").readLines().size) + } + } + + @Test + fun theSearchFitsTheWindowsCommandLine() { + // Git Bash gets the command as `bash.exe -c ""` with each quote written \" and the line is cut at 8191 characters. + val chats = (1..12).map { ChatSearch.Chat("p$it", AgentSessionRef(kind = "pid", value = "${16000 + it}", agent = "claude")) } + val line = HerdrCommands.login(ChatSearch.command("flaky", chats)!!).replace("\"", "\\\"") + val room = 8191 - "\"C:\\Program Files\\Git\\bin\\bash.exe\" -c \"".length - 1 + assertTrue(line.length <= room, "the search is ${line.length} characters, the room is $room") + } + @Test fun searchWithoutAMatchStaysEmpty() { assertEquals(emptyList(), search("missing")) From 47aaccfca7106527a49fecdec23964babc9e9334 Mon Sep 17 00:00:00 2001 From: Manuel Puche <229537713+puchedev@users.noreply.github.com> Date: Sat, 7 Nov 2026 21:09:20 -0500 Subject: [PATCH 10/10] Chat search: commands that fit Windows' line, however many chats Windows hands the command to Git's bash on a line cut at 8191 characters (each double quote counting twice), and a cut command fails whole. A machine with more agents than fit gets its search in more than one command, run one after another, so no chat is skipped. The process table is read once per command even when PowerShell gives nothing back. Co-Authored-By: Claude Opus 5.5 --- .../herdr/ui/search/SearchScreen.kt | 7 +++- .../herdr/core/chat/ChatSearch.kt | 41 +++++++++++++++---- .../herdr/core/chat/TranscriptFollower.kt | 6 +-- .../com/makefunstuff/herdr/core/SearchTest.kt | 12 +++--- .../herdr/core/TranscriptWindowsTest.kt | 22 ++++++++-- .../src/main/kotlin/preview/FakeMachine.kt | 2 +- 6 files changed, 67 insertions(+), 23 deletions(-) diff --git a/app/src/main/kotlin/com/makefunstuff/herdr/ui/search/SearchScreen.kt b/app/src/main/kotlin/com/makefunstuff/herdr/ui/search/SearchScreen.kt index 9c6f2ec2..01312fd8 100644 --- a/app/src/main/kotlin/com/makefunstuff/herdr/ui/search/SearchScreen.kt +++ b/app/src/main/kotlin/com/makefunstuff/herdr/ui/search/SearchScreen.kt @@ -129,11 +129,14 @@ fun SearchScreen(nav: NavController, machineId: String? = null, initial: String online.map { h -> async { val chats = h.view!!.agents.mapNotNull { a -> a.agentSession?.let { ChatSearch.Chat(a.paneId, it) } } - val command = ChatSearch.command(q, chats) ?: return@async + val commands = ChatSearch.commands(q, chats).ifEmpty { return@async } looking++ try { val session = container.hosts.session(h.machine.id) ?: return@async - val out = runCatching { session.awaitConnection(10_000).run(command, timeoutSeconds = 15).stdout }.getOrNull() ?: return@async + val out = runCatching { + val conn = session.awaitConnection(10_000) + buildString { for (c in commands) append(conn.run(c, timeoutSeconds = 15).stdout).append('\n') } + }.getOrNull() ?: return@async val kinds = h.view!!.agents.associate { it.paneId to (it.agentSession?.agent ?: it.agent.orEmpty()) } chatHits[h.machine.id] = ChatSearch.parse(out, q, kinds) } finally { diff --git a/core/src/main/kotlin/com/makefunstuff/herdr/core/chat/ChatSearch.kt b/core/src/main/kotlin/com/makefunstuff/herdr/core/chat/ChatSearch.kt index 91c7af12..e907079c 100644 --- a/core/src/main/kotlin/com/makefunstuff/herdr/core/chat/ChatSearch.kt +++ b/core/src/main/kotlin/com/makefunstuff/herdr/core/chat/ChatSearch.kt @@ -7,7 +7,7 @@ import java.time.Instant /** * Words in the recent chats: the conversations of the agents running on a machine, searched where - * they are. One command per machine finds each agent's transcript as the chat does + * they are. One command per machine (a few for many agents) finds each agent's transcript as the chat does * ([TranscriptFollower.FIND]) and prints its newest lines holding the words, which the agent's own * reader then turns into what was said. Only what was said counts, not tool calls or their output. * As in the rest of the search, every word has to be in it, anywhere: the machine looks for the @@ -44,15 +44,42 @@ object ChatSearch { /** Something said in a chat that holds the words: a piece of it around them, and when, when the transcript says. */ data class Hit(val paneId: String, val you: Boolean, val snippet: String, val at: Long?) - /** The command for [chats] on one machine; null when there is nothing to look in. */ - fun command(query: String, chats: List): String? { - val q = words(query).maxByOrNull { it.length } ?: return null + /** + * The longest command sent, counting each double quote twice: Windows hands it to Git's bash on a + * command line cut at 8191 characters, with every `"` written `\"`, and a cut command fails whole. + */ + private const val MAX_COMMAND = 7_800 + + /** + * The commands for [chats] on one machine, run one after another; none when there is nothing to look + * in. Each stays within what Windows takes, so a machine with many agents gets more than one. + */ + fun commands(query: String, chats: List): List { + val q = words(query).maxByOrNull { it.length } ?: return emptyList() val readable = chats.filter { Transcript.forAgent(it.session.agent) != null && it.session.value.isNotEmpty() } - if (query.trim().length < MIN_QUERY || readable.isEmpty()) return null - val args = readable.flatMap { listOf(it.paneId, it.session.kind, it.session.value, it.session.agent) } - return "sh -c ${quote(SCRIPT)} herdr-chat-search " + (listOf(q) + args).joinToString(" ") { quote(it) } + if (query.trim().length < MIN_QUERY || readable.isEmpty()) return emptyList() + val head = "sh -c ${quote(SCRIPT)} herdr-chat-search ${quote(q)}" + val out = ArrayList() + val command = StringBuilder(head) + var length = windowsLength(head) + for (chat in readable) { + val part = listOf(chat.paneId, chat.session.kind, chat.session.value, chat.session.agent).joinToString("") { " " + quote(it) } + if (command.length > head.length && length + windowsLength(part) > MAX_COMMAND) { + out += command.toString() + command.setLength(0) + command.append(head) + length = windowsLength(head) + } + command.append(part) + length += windowsLength(part) + } + out += command.toString() + return out } + /** How long [s] is on a Windows command line, where each double quote is written `\"`. */ + private fun windowsLength(s: CharSequence): Int = s.length + s.count { it == '"' } + /** * The hits in what [command] printed: the newest first within each chat, at most [perChat] of * them. [agents] gives each pane's agent kind, whose reader understands its lines. diff --git a/core/src/main/kotlin/com/makefunstuff/herdr/core/chat/TranscriptFollower.kt b/core/src/main/kotlin/com/makefunstuff/herdr/core/chat/TranscriptFollower.kt index c2d657ff..86f6911c 100644 --- a/core/src/main/kotlin/com/makefunstuff/herdr/core/chat/TranscriptFollower.kt +++ b/core/src/main/kotlin/com/makefunstuff/herdr/core/chat/TranscriptFollower.kt @@ -42,9 +42,9 @@ object TranscriptFollower { case "${'$'}(uname -s 2>/dev/null)" in MINGW*|MSYS*|CYGWIN*) # Git Bash has no pgrep and can't see Windows processes. Read the table of every process and - # its parent (once for all the chats of a search: ptable stays set) and walk it down from the - # pid; a parent id can be stale (Windows reuses pids), so each pid is taken once. - [ -n "${'$'}ptable" ] || ptable=${'$'}(powershell.exe -NoProfile -Command 'Get-CimInstance Win32_Process | Format-Table -HideTableHeaders ProcessId,ParentProcessId' 2>/dev/null /dev/null { - val command = ChatSearch.command(q, listOf(ChatSearch.Chat("w1:p1", AgentSessionRef(agent = "claude", kind = "path", value = file.path))))!! + val command = ChatSearch.commands(q, listOf(ChatSearch.Chat("w1:p1", AgentSessionRef(agent = "claude", kind = "path", value = file.path)))).single() val p = ProcessBuilder("sh", "-c", command).redirectErrorStream(true).start() val out = p.inputStream.bufferedReader().readText() assertTrue(p.waitFor(10, TimeUnit.SECONDS)) @@ -224,9 +224,9 @@ class ChatSearchTest { @Test fun aShortQueryOrAChatItCantReadAsksNothing() { val chat = ChatSearch.Chat("w1:p1", AgentSessionRef(agent = "claude", kind = "id", value = "abc")) - assertNull(ChatSearch.command("jo", listOf(chat))) - assertNull(ChatSearch.command("jobs", listOf(chat.copy(session = chat.session.copy(agent = "gemini"))))) - assertNull(ChatSearch.command("jobs", emptyList())) + assertTrue(ChatSearch.commands("jo", listOf(chat)).isEmpty()) + assertTrue(ChatSearch.commands("jobs", listOf(chat.copy(session = chat.session.copy(agent = "gemini")))).isEmpty()) + assertTrue(ChatSearch.commands("jobs", emptyList()).isEmpty()) } @Test diff --git a/core/src/test/kotlin/com/makefunstuff/herdr/core/TranscriptWindowsTest.kt b/core/src/test/kotlin/com/makefunstuff/herdr/core/TranscriptWindowsTest.kt index 0f23ac7e..fd578ed3 100644 --- a/core/src/test/kotlin/com/makefunstuff/herdr/core/TranscriptWindowsTest.kt +++ b/core/src/test/kotlin/com/makefunstuff/herdr/core/TranscriptWindowsTest.kt @@ -3,7 +3,6 @@ package com.makefunstuff.herdr.core import com.makefunstuff.herdr.core.api.AgentSessionRef import com.makefunstuff.herdr.core.chat.ChatSearch import com.makefunstuff.herdr.core.chat.TranscriptFollower -import com.makefunstuff.herdr.core.ssh.HerdrCommands import java.io.File import java.nio.file.Files import java.util.concurrent.TimeUnit @@ -115,7 +114,7 @@ class TranscriptWindowsTest { onWindows("16744 900\n27492 16744\n", sessionPid = "27492", jsonl = said) { home, bin -> setup(bin) val chat = ChatSearch.Chat("p1", AgentSessionRef(kind = "pid", value = "16744", agent = "claude")) - val stdout = sh(home, bin, ChatSearch.command(query, listOf(chat))!!) + val stdout = sh(home, bin, ChatSearch.commands(query, listOf(chat)).single()) ChatSearch.parse(stdout, query, mapOf("p1" to "claude")) } @@ -145,7 +144,7 @@ class TranscriptWindowsTest { val chats = listOf("16744" to "p1", "17000" to "p2", "18000" to "p3").map { (pid, pane) -> ChatSearch.Chat(pane, AgentSessionRef(kind = "pid", value = pid, agent = "claude")) } - val stdout = sh(home, bin, ChatSearch.command("flaky", chats)!!) + val stdout = sh(home, bin, ChatSearch.commands("flaky", chats).single()) assertEquals(listOf("p1", "p2", "p3"), ChatSearch.parse(stdout, "flaky", chats.associate { it.paneId to "claude" }).map { it.paneId }) assertEquals(1, File(home, "powershell.calls").readLines().size) } @@ -155,11 +154,26 @@ class TranscriptWindowsTest { fun theSearchFitsTheWindowsCommandLine() { // Git Bash gets the command as `bash.exe -c ""` with each quote written \" and the line is cut at 8191 characters. val chats = (1..12).map { ChatSearch.Chat("p$it", AgentSessionRef(kind = "pid", value = "${16000 + it}", agent = "claude")) } - val line = HerdrCommands.login(ChatSearch.command("flaky", chats)!!).replace("\"", "\\\"") + val line = ChatSearch.commands("flaky", chats).single().replace("\"", "\\\"") val room = 8191 - "\"C:\\Program Files\\Git\\bin\\bash.exe\" -c \"".length - 1 assertTrue(line.length <= room, "the search is ${line.length} characters, the room is $room") } + @Test + fun aSearchOfManyChatsIsSplitToFitWindowsLine() { + // A machine with a few hundred agents: several commands, each on Windows' line, and every chat in one of them. + val chats = (1..300).map { ChatSearch.Chat("p$it", AgentSessionRef(kind = "path", value = "~/.claude/projects/C--Users-dev-proj/$session.jsonl", agent = "claude")) } + val commands = ChatSearch.commands("flaky", chats) + assertTrue(commands.size > 1, "${commands.size} commands") + val room = 8191 - "\"C:\\Program Files\\Git\\bin\\bash.exe\" -c \"".length - 1 + for (command in commands) { + val line = command.replace("\"", "\\\"") + assertTrue(line.length <= room, "a command is ${line.length} characters, the room is $room") + } + val panes = commands.flatMap { c -> Regex("'(p\\d+)' 'path'").findAll(c).map { it.groupValues[1] }.toList() } + assertEquals(chats.map { it.paneId }, panes) + } + @Test fun searchWithoutAMatchStaysEmpty() { assertEquals(emptyList(), search("missing")) diff --git a/tools/ui-preview/src/main/kotlin/preview/FakeMachine.kt b/tools/ui-preview/src/main/kotlin/preview/FakeMachine.kt index ec25ff20..59f6b565 100644 --- a/tools/ui-preview/src/main/kotlin/preview/FakeMachine.kt +++ b/tools/ui-preview/src/main/kotlin/preview/FakeMachine.kt @@ -123,7 +123,7 @@ class FakeMachine( appBuild?.run(command)?.let { return it } docs?.run(command)?.let { return it } Shell.words(command).let { w -> - // Words in the chats (ChatSearch.command): the transcripts' lines that hold them, under each chat's mark. + // Words in the chats (ChatSearch.commands): the transcripts' lines that hold them, under each chat's mark. val chat = w.indexOf("herdr-chat-search") if (chat >= 0) { val q = w[chat + 1]