@@ -34,12 +30,10 @@ export function StatusBar() {
return (
+ {/* The branch selector moved to the below-composer folder/branch row; the
+ left side now carries just the workspace stats. */}
- {/* Branch selector (moved from the aux "session details" tab). Folder
- name is hidden — just the branch — since the folder chip now lives in
- the conversation header. Self-hides in chat mode / without a repo. */}
-
diff --git a/src/components/message/ask-question-result-card.test.tsx b/src/components/message/ask-question-result-card.test.tsx
index cee949074..6e7939df1 100644
--- a/src/components/message/ask-question-result-card.test.tsx
+++ b/src/components/message/ask-question-result-card.test.tsx
@@ -235,6 +235,18 @@ describe("AskQuestionResultCard", () => {
expect(screen.queryByRole("radio")).toBeNull()
})
+ it("renders nothing for an in-flight question whose input hasn't streamed in", () => {
+ // claude-agent-acp's arg-less initial tool_call leaves the input empty, so no
+ // question parses. The live question is answered via the pinned card, so this
+ // in-stream placeholder is pure noise — it must not render an anonymous
+ // "awaiting your answer" card (they stacked into visible duplicates).
+ const { container } = renderWithIntl(
+
+ )
+ expect(container.firstChild).toBeNull()
+ expect(screen.queryByText(result.awaiting)).not.toBeInTheDocument()
+ })
+
it("matches grok's header-less questions to their empty-header answers", () => {
// Grok's native ask carries no `header`; the connection bridge (live) and the
// history parser both emit header-less questions + answers with `header: ""`.
diff --git a/src/components/message/ask-question-result-card.tsx b/src/components/message/ask-question-result-card.tsx
index 610525b08..eb4924deb 100644
--- a/src/components/message/ask-question-result-card.tsx
+++ b/src/components/message/ask-question-result-card.tsx
@@ -140,17 +140,24 @@ export function AskQuestionResultCard({
}
if (isInFlight) {
+ // A live pending question is answered through the PINNED AskQuestionCard
+ // (driven by the connection's `pendingAskQuestion`), not this in-stream
+ // record. Until the question text streams onto the wire, claude-agent-acp's
+ // arg-less initial `tool_call` leaves `input` empty, so `questions` is []:
+ // rendering the bare "awaiting your answer" placeholder here only stacks
+ // anonymous duplicates of the same wait — one per in-flight (or stranded)
+ // question call. Drop it; the card reappears with its Q&A the moment the
+ // input or the answer resolves, and the settled transcript is unaffected.
+ if (questions.length === 0) return null
return shell(
t("awaiting"),
- questions.length > 0 ? (
-
- {questions.map((q, i) => (
-
- {q.header || q.question}
-
- ))}
-
- ) : undefined
+
+ {questions.map((q, i) => (
+
+ {q.header || q.question}
+
+ ))}
+
)
}
diff --git a/src/components/message/delegation-status-card.test.tsx b/src/components/message/delegation-status-card.test.tsx
index 742432ad2..4eec94468 100644
--- a/src/components/message/delegation-status-card.test.tsx
+++ b/src/components/message/delegation-status-card.test.tsx
@@ -69,12 +69,33 @@ describe("DelegationStatusCard", () => {
})
it("falls back to a task-less label when the task_id can't be parsed", () => {
+ // A settled poll that returned a note but no resolvable id still shows the
+ // task-less label (there is content to surface).
renderWithIntl(
-
+
)
expect(screen.getByText("Waiting for task result")).toBeInTheDocument()
})
+ it("suppresses a still-in-flight poll whose task_id hasn't streamed in yet", () => {
+ // claude-agent-acp ships an arg-less initial tool_call (rawInput not yet on
+ // the wire), so a live in-flight poll carries no identity and nothing to
+ // show. It must NOT render as an anonymous "waiting for a task's result"
+ // row (that stacked into visible duplicates); it appears once its id or a
+ // report resolves.
+ const { container } = renderWithIntl(
+
+ )
+ expect(container.firstChild).toBeNull()
+ expect(
+ screen.queryByText("Waiting for task result")
+ ).not.toBeInTheDocument()
+ })
+
it("expands a plain streamed result inline (no child-session button)", () => {
renderWithIntl(
{
expect(container.querySelector(".animate-spin")).toBeInTheDocument()
})
+ it("suppresses an in-flight poll whose task_ids have not streamed in yet", () => {
+ // claude-agent-acp ships an arg-less initial tool_call (rawInput `{}`) and
+ // fills the real `task_ids` only on a later update, so a still-blocking live
+ // poll has neither an input id nor an output report. It must NOT render as an
+ // anonymous "waiting for a task's result" row.
+ const { container } = renderWithIntl(
+
+ )
+ expect(container.firstChild).toBeNull()
+ expect(
+ screen.queryByText("Waiting for task result")
+ ).not.toBeInTheDocument()
+ })
+
+ it("does not stack repeated id-less in-flight re-polls into duplicate rows", () => {
+ // The reported bug: N concurrent/re-issued anonymous polls each became a
+ // separate "waiting for a task's result" row. With one task attributed via
+ // its returned report and three id-less in-flight checks, only the attributed
+ // row survives.
+ renderWithIntl(
+
+ )
+ expect(
+ screen.getByText("Waiting for task #abc12345 result")
+ ).toBeInTheDocument()
+ expect(
+ screen.queryByText("Waiting for task result")
+ ).not.toBeInTheDocument()
+ })
+
it("renders one row per task for parallel waits", () => {
renderWithIntl(
(null)
// Guard parsing behind completion so mid-stream renders stay diff-free.
const files = useMemo(
@@ -78,9 +77,9 @@ export const ReplyArtifacts = memo(function ReplyArtifacts({
[isResponseComplete, sourceTurns]
)
- // Split created files (their own cards) from modified/removed files (the
- // accordion). Removal wins over creation, so a create+delete in the same
- // reply lands in "changed", not "new files".
+ // Split created files from modified/removed files — each lands in its own
+ // section ("New files" vs "Files changed"). Removal wins over creation, so a
+ // create+delete in the same reply lands in "changed", not "new files".
const { addedFiles, changedFiles } = useMemo(() => {
const addedFiles: FileChangeStat[] = []
const changedFiles: FileChangeStat[] = []
@@ -258,101 +257,119 @@ export const ReplyArtifacts = memo(function ReplyArtifacts({
{changedOpen && (
-
- {changedFiles.map((file) => {
- const displayPath = toFolderRelativePath(file.path, folderPath)
- const name = fileNameOf(displayPath)
- const dir =
- displayPath === name
- ? ""
- : displayPath.slice(0, displayPath.length - name.length - 1)
- const isRemoved = isRemovedFileDiff(file.diff)
- const isOpen = openPath === file.path
+
+
+
+ {changedFiles.map((file) => {
+ const displayPath = toFolderRelativePath(
+ file.path,
+ folderPath
+ )
+ const name = fileNameOf(displayPath)
+ const dir =
+ displayPath === name
+ ? ""
+ : displayPath.slice(
+ 0,
+ displayPath.length - name.length - 1
+ )
+ const isRemoved = isRemovedFileDiff(file.diff)
- return (
-
-
-
+
+ {t("remove")}
+
+
+ )
+ }
+
+ return (
+
+
+
+
+
+
+ {t("openInEditor")}
+
+
- {isOpen &&
- (file.diff ? (
-
- ) : (
-
- {t("noDiffDataAvailable", { filePath: displayPath })}
-
- ))}
-
- )
- })}
-
+ {isLocalDesktop() && (
+
+
+
+
+
+ {t("revealInFolder")}
+
+
+ )}
+
+ )
+ })}
+
+
+
)}
)}
diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json
index d00a6cefc..dd54d376a 100644
--- a/src/i18n/messages/ar.json
+++ b/src/i18n/messages/ar.json
@@ -1372,6 +1372,7 @@
"sectionFolders": "المجلدات",
"sectionChats": "محادثة",
"noChats": "لا توجد محادثات",
+ "noFolders": "لا توجد مجلدات مفتوحة",
"newChatAction": "محادثة جديدة",
"automations": "الأتمتة",
"loadingSubsessions": "جارٍ تحميل المحادثات الفرعية…"
@@ -1737,7 +1738,11 @@
"confirmApply": "تطبيق التخبئة {ref} على دليل العمل؟",
"cancel": "إلغاء"
},
- "deleteBranch": "حذف الفرع"
+ "deleteBranch": "حذف الفرع",
+ "searchPlaceholder": "البحث في الفروع والإجراءات",
+ "searchAriaLabel": "البحث في الفروع والإجراءات",
+ "branchListLabel": "الفروع والإجراءات",
+ "noMatches": "لا توجد نتائج"
},
"commitDialog": {
"toasts": {
diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json
index e926ed601..d80a5b69f 100644
--- a/src/i18n/messages/de.json
+++ b/src/i18n/messages/de.json
@@ -1372,6 +1372,7 @@
"sectionFolders": "Ordner",
"sectionChats": "Chat",
"noChats": "Keine Chats",
+ "noFolders": "Keine Ordner geöffnet",
"newChatAction": "Neuer Chat",
"automations": "Automatisierungen",
"loadingSubsessions": "Unterkonversationen werden geladen…"
@@ -1737,7 +1738,11 @@
"confirmApply": "Stash {ref} auf das Arbeitsverzeichnis anwenden?",
"cancel": "Abbrechen"
},
- "deleteBranch": "Branch löschen"
+ "deleteBranch": "Branch löschen",
+ "searchPlaceholder": "Branches und Aktionen suchen",
+ "searchAriaLabel": "Branches und Aktionen suchen",
+ "branchListLabel": "Branches und Aktionen",
+ "noMatches": "Keine Treffer"
},
"commitDialog": {
"toasts": {
diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json
index 359b54268..783c55221 100644
--- a/src/i18n/messages/en.json
+++ b/src/i18n/messages/en.json
@@ -1372,6 +1372,7 @@
"sectionFolders": "Folders",
"sectionChats": "Chat",
"noChats": "No chats",
+ "noFolders": "No folders open",
"newChatAction": "New chat",
"automations": "Automations",
"loadingSubsessions": "Loading sub-conversations…"
@@ -1737,7 +1738,11 @@
"confirmApply": "Apply stash {ref} to working directory?",
"cancel": "Cancel"
},
- "deleteBranch": "Delete branch"
+ "deleteBranch": "Delete branch",
+ "searchPlaceholder": "Search branches and actions",
+ "searchAriaLabel": "Search branches and actions",
+ "branchListLabel": "Branches and actions",
+ "noMatches": "No matches"
},
"commitDialog": {
"toasts": {
diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json
index 56eddbce5..55ac967f5 100644
--- a/src/i18n/messages/es.json
+++ b/src/i18n/messages/es.json
@@ -1372,6 +1372,7 @@
"sectionFolders": "Carpetas",
"sectionChats": "Chat",
"noChats": "Sin chats",
+ "noFolders": "No hay carpetas abiertas",
"newChatAction": "Nuevo chat",
"automations": "Automatizaciones",
"loadingSubsessions": "Cargando subconversaciones…"
@@ -1737,7 +1738,11 @@
"confirmApply": "¿Aplicar stash {ref} al directorio de trabajo?",
"cancel": "Cancelar"
},
- "deleteBranch": "Eliminar rama"
+ "deleteBranch": "Eliminar rama",
+ "searchPlaceholder": "Buscar ramas y acciones",
+ "searchAriaLabel": "Buscar ramas y acciones",
+ "branchListLabel": "Ramas y acciones",
+ "noMatches": "Sin coincidencias"
},
"commitDialog": {
"toasts": {
diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json
index 5e888e7d6..0b71da52e 100644
--- a/src/i18n/messages/fr.json
+++ b/src/i18n/messages/fr.json
@@ -1372,6 +1372,7 @@
"sectionFolders": "Dossiers",
"sectionChats": "Discussion",
"noChats": "Aucune discussion",
+ "noFolders": "Aucun dossier ouvert",
"newChatAction": "Nouvelle discussion",
"automations": "Automatisations",
"loadingSubsessions": "Chargement des sous-conversations…"
@@ -1737,7 +1738,11 @@
"confirmApply": "Appliquer la remise {ref} au répertoire de travail ?",
"cancel": "Annuler"
},
- "deleteBranch": "Supprimer la branche"
+ "deleteBranch": "Supprimer la branche",
+ "searchPlaceholder": "Rechercher des branches et actions",
+ "searchAriaLabel": "Rechercher des branches et actions",
+ "branchListLabel": "Branches et actions",
+ "noMatches": "Aucune correspondance"
},
"commitDialog": {
"toasts": {
diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json
index d4e820fb0..fbd1be4ce 100644
--- a/src/i18n/messages/ja.json
+++ b/src/i18n/messages/ja.json
@@ -1372,6 +1372,7 @@
"sectionFolders": "フォルダ",
"sectionChats": "チャット",
"noChats": "チャットがありません",
+ "noFolders": "開いているフォルダがありません",
"newChatAction": "新しいチャット",
"automations": "オートメーション",
"loadingSubsessions": "サブ会話を読み込み中…"
@@ -1737,7 +1738,11 @@
"confirmApply": "スタッシュ {ref} を作業ディレクトリに適用しますか?",
"cancel": "キャンセル"
},
- "deleteBranch": "ブランチを削除"
+ "deleteBranch": "ブランチを削除",
+ "searchPlaceholder": "ブランチと操作を検索",
+ "searchAriaLabel": "ブランチと操作を検索",
+ "branchListLabel": "ブランチと操作",
+ "noMatches": "一致する項目がありません"
},
"commitDialog": {
"toasts": {
diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json
index 120eed32e..68db8fb40 100644
--- a/src/i18n/messages/ko.json
+++ b/src/i18n/messages/ko.json
@@ -1372,6 +1372,7 @@
"sectionFolders": "폴더",
"sectionChats": "채팅",
"noChats": "채팅 없음",
+ "noFolders": "열린 폴더 없음",
"newChatAction": "새 채팅",
"automations": "자동화",
"loadingSubsessions": "하위 대화 로드 중…"
@@ -1737,7 +1738,11 @@
"confirmApply": "스태시 {ref}을(를) 작업 디렉토리에 적용하시겠습니까?",
"cancel": "취소"
},
- "deleteBranch": "브랜치 삭제"
+ "deleteBranch": "브랜치 삭제",
+ "searchPlaceholder": "브랜치 및 작업 검색",
+ "searchAriaLabel": "브랜치 및 작업 검색",
+ "branchListLabel": "브랜치 및 작업",
+ "noMatches": "일치하는 항목 없음"
},
"commitDialog": {
"toasts": {
diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json
index 20380762b..ff331c024 100644
--- a/src/i18n/messages/pt.json
+++ b/src/i18n/messages/pt.json
@@ -1372,6 +1372,7 @@
"sectionFolders": "Pastas",
"sectionChats": "Chat",
"noChats": "Sem chats",
+ "noFolders": "Nenhuma pasta aberta",
"newChatAction": "Novo chat",
"automations": "Automações",
"loadingSubsessions": "Carregando subconversas…"
@@ -1737,7 +1738,11 @@
"confirmApply": "Aplicar stash {ref} ao diretório de trabalho?",
"cancel": "Cancelar"
},
- "deleteBranch": "Excluir branch"
+ "deleteBranch": "Excluir branch",
+ "searchPlaceholder": "Pesquisar ramos e ações",
+ "searchAriaLabel": "Pesquisar ramos e ações",
+ "branchListLabel": "Ramos e ações",
+ "noMatches": "Nenhuma correspondência"
},
"commitDialog": {
"toasts": {
diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json
index 341b37d93..6c11d56f6 100644
--- a/src/i18n/messages/zh-CN.json
+++ b/src/i18n/messages/zh-CN.json
@@ -1372,6 +1372,7 @@
"sectionFolders": "文件夹",
"sectionChats": "聊天",
"noChats": "没有聊天",
+ "noFolders": "没有打开的文件夹",
"newChatAction": "新建聊天",
"automations": "自动化",
"loadingSubsessions": "正在加载子会话…"
@@ -1737,7 +1738,11 @@
"confirmApply": "将贮藏 {ref} 应用到工作目录?",
"cancel": "取消"
},
- "deleteBranch": "删除分支"
+ "deleteBranch": "删除分支",
+ "searchPlaceholder": "搜索分支和操作",
+ "searchAriaLabel": "搜索分支和操作",
+ "branchListLabel": "分支和操作",
+ "noMatches": "无匹配项"
},
"commitDialog": {
"toasts": {
diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json
index 53bee8190..31bf100f2 100644
--- a/src/i18n/messages/zh-TW.json
+++ b/src/i18n/messages/zh-TW.json
@@ -1372,6 +1372,7 @@
"sectionFolders": "資料夾",
"sectionChats": "聊天",
"noChats": "沒有聊天",
+ "noFolders": "沒有開啟的資料夾",
"newChatAction": "新增聊天",
"automations": "自動化",
"loadingSubsessions": "正在載入子會話…"
@@ -1737,7 +1738,11 @@
"confirmApply": "將貯藏 {ref} 套用到工作目錄?",
"cancel": "取消"
},
- "deleteBranch": "刪除分支"
+ "deleteBranch": "刪除分支",
+ "searchPlaceholder": "搜尋分支與操作",
+ "searchAriaLabel": "搜尋分支與操作",
+ "branchListLabel": "分支與操作",
+ "noMatches": "無相符項目"
},
"commitDialog": {
"toasts": {
diff --git a/src/lib/background-task.test.ts b/src/lib/background-task.test.ts
index 7c4bb6a71..90978775c 100644
--- a/src/lib/background-task.test.ts
+++ b/src/lib/background-task.test.ts
@@ -249,6 +249,63 @@ describe("buildBackgroundTaskRows", () => {
expect(rows[0].isInFlight).toBe(true)
})
+ it("drops an in-flight poll whose task_id has not streamed in yet", () => {
+ // claude-agent-acp emits an arg-less initial tool_call (rawInput `{}`) and
+ // fills the real args only on a later update, so a still-blocking live poll
+ // has no envelope and no input id. It must NOT render as an anonymous row.
+ const rows = buildBackgroundTaskRows([
+ poll({ input: "{}", output: null, state: "input-available" }),
+ ])
+ expect(rows).toHaveLength(0)
+ })
+
+ it("does not stack repeated id-less in-flight re-polls into duplicate rows", () => {
+ // The reported bug: N concurrent/re-issued anonymous polls each became a
+ // separate "background task running" row. With one real task attributed and
+ // three id-less in-flight checks, only the attributed row survives.
+ const rows = buildBackgroundTaskRows([
+ poll({
+ toolCallId: "done",
+ output: COMPLETED,
+ state: "output-available",
+ }),
+ poll({
+ toolCallId: "x1",
+ input: "{}",
+ output: null,
+ state: "input-available",
+ }),
+ poll({
+ toolCallId: "x2",
+ input: null,
+ output: null,
+ state: "input-available",
+ }),
+ poll({
+ toolCallId: "x3",
+ input: "{}",
+ output: null,
+ state: "input-streaming",
+ }),
+ ])
+ expect(rows).toHaveLength(1)
+ expect(rows[0].taskId).toBe("bfb5xnq1t")
+ })
+
+ it("still shows an id-less poll once it carries an envelope", () => {
+ // A settled/output-available poll that parsed an envelope (even without a
+ // resolvable id) is real content and keeps its row.
+ const noIdEnvelope = `
success
+
completed
+
0
+
`
+ const rows = buildBackgroundTaskRows([
+ poll({ input: null, output: noIdEnvelope, state: "output-available" }),
+ ])
+ expect(rows).toHaveLength(1)
+ expect(rows[0].badge).toBe("completed")
+ })
+
it("derives a stopped badge + command from a TaskStop ack", () => {
const rows = buildBackgroundTaskRows([
poll({
diff --git a/src/lib/background-task.ts b/src/lib/background-task.ts
index 7ea86c9ff..6017db57c 100644
--- a/src/lib/background-task.ts
+++ b/src/lib/background-task.ts
@@ -275,6 +275,18 @@ export function buildBackgroundTaskRows(
poll.output ?? poll.errorText ?? null
)
const taskId = envelope?.taskId ?? inputTaskId(poll.input) ?? null
+ // Drop an in-flight poll that carries no identity AND no output yet — a live
+ // `TaskOutput` whose `task_id` hasn't streamed onto the wire. claude-agent-acp
+ // emits an arg-less initial `tool_call` (rawInput `{}`) and fills the real
+ // args only on a later update, so a still-blocking poll parses to no envelope
+ // and no input id. Each such re-poll would otherwise render as its own
+ // anonymous "background task running" row, stacking identical duplicates of
+ // the same wait; it folds into its task's row once the id resolves (and the
+ // settled transcript, whose polls always carry the id, is unaffected).
+ // Mirrors `buildDelegationTaskRows`.
+ if (taskId == null && envelope == null && isInFlightState(poll)) {
+ continue
+ }
const key = taskId ?? `__bg__:${poll.toolCallId}`
let entry = byKey.get(key)
if (!entry) {
diff --git a/src/lib/branch-selector-rows.test.ts b/src/lib/branch-selector-rows.test.ts
new file mode 100644
index 000000000..a09b9ed2a
--- /dev/null
+++ b/src/lib/branch-selector-rows.test.ts
@@ -0,0 +1,304 @@
+import { describe, expect, it } from "vitest"
+
+import {
+ buildBranchRows,
+ isNavigableRow,
+ type BranchOperationMeta,
+ type BranchRow,
+ type BuildBranchRowsInput,
+} from "@/lib/branch-selector-rows"
+import {
+ buildBranchTree,
+ buildRemoteBranchSections,
+ localBranchItems,
+ sectionKey,
+ type BranchTreeNode,
+} from "@/lib/branch-tree"
+
+const OPS: BranchOperationMeta[] = [
+ { id: "pull", label: "Pull code" },
+ { id: "push", label: "Push..." },
+]
+
+function localTree(names: string[]): BranchTreeNode[] {
+ return buildBranchTree(localBranchItems(names), "local")
+}
+
+// Compact, readable shape for sequence assertions.
+function summarize(row: BranchRow): string {
+ switch (row.kind) {
+ case "operation":
+ return `op:${row.opId}`
+ case "separator":
+ return "sep"
+ case "section":
+ return `section:${row.scope}(${row.count})${row.expanded ? "+" : "-"}`
+ case "group":
+ return `group:${row.label}@${row.depth}${row.expanded ? "+" : "-"}`
+ case "leaf":
+ return `leaf:${row.fullName}@${row.depth}${row.isCurrent ? "*" : ""}`
+ case "empty":
+ return `empty:${row.scope}`
+ }
+}
+
+function baseInput(
+ overrides: Partial
= {}
+): BuildBranchRowsInput {
+ return {
+ operations: OPS,
+ localNodes: [],
+ remoteSections: [],
+ localCount: 0,
+ remoteCount: 0,
+ branch: null,
+ worktreeBranchSet: new Set(),
+ collapsed: new Set(),
+ query: "",
+ ...overrides,
+ }
+}
+
+const LOCAL = [
+ "main",
+ "feature/auth/login",
+ "feature/auth/logout",
+ "release/1.0",
+]
+
+describe("buildBranchRows — empty query (tree mode)", () => {
+ it("puts operations first, a separator, then default-expanded sections + flattened tree", () => {
+ const rows = buildBranchRows(
+ baseInput({ localNodes: localTree(LOCAL), localCount: 4, remoteCount: 0 })
+ )
+ expect(rows.map(summarize)).toEqual([
+ "op:pull",
+ "op:push",
+ "sep",
+ "section:local(4)+",
+ "group:feature/auth/@1+",
+ "leaf:feature/auth/login@2",
+ "leaf:feature/auth/logout@2",
+ "leaf:main@1",
+ "leaf:release/1.0@1",
+ "section:remote(0)+",
+ "empty:remote",
+ ])
+ })
+
+ it("collapsing the local section hides all its children", () => {
+ const rows = buildBranchRows(
+ baseInput({
+ localNodes: localTree(LOCAL),
+ localCount: 4,
+ collapsed: new Set([sectionKey("local")]),
+ })
+ )
+ expect(rows.map(summarize)).toEqual([
+ "op:pull",
+ "op:push",
+ "sep",
+ "section:local(4)-",
+ "section:remote(0)+",
+ "empty:remote",
+ ])
+ })
+
+ it("collapsing a prefix group hides only that subtree", () => {
+ const nodes = localTree(LOCAL)
+ const groupKey = nodes.find((n) => n.type === "group")!.key
+ const rows = buildBranchRows(
+ baseInput({
+ localNodes: nodes,
+ localCount: 4,
+ collapsed: new Set([groupKey]),
+ })
+ )
+ expect(rows.map(summarize)).toEqual([
+ "op:pull",
+ "op:push",
+ "sep",
+ "section:local(4)+",
+ "group:feature/auth/@1-",
+ "leaf:main@1",
+ "leaf:release/1.0@1",
+ "section:remote(0)+",
+ "empty:remote",
+ ])
+ })
+})
+
+describe("buildBranchRows — leaf flags (drive the action bubble)", () => {
+ it("emits no leaf-action rows — actions live in the right-side bubble now", () => {
+ const rows = buildBranchRows(
+ baseInput({ localNodes: localTree(LOCAL), localCount: 4, branch: "main" })
+ )
+ expect(rows.map(summarize)).toContain("leaf:main@1*")
+ const validKinds = new Set([
+ "operation",
+ "separator",
+ "section",
+ "group",
+ "leaf",
+ "empty",
+ ])
+ expect(rows.every((r) => validKinds.has(r.kind))).toBe(true)
+ })
+
+ it("marks the remote leaf that tracks the current local branch (bubble hides its delete)", () => {
+ const remoteSections = buildRemoteBranchSections([
+ "origin/main",
+ "origin/dev",
+ ])
+ const rows = buildBranchRows(
+ baseInput({ remoteSections, remoteCount: 2, branch: "main" })
+ )
+ const tracking = rows.find(
+ (r) => r.kind === "leaf" && r.fullName === "origin/main"
+ )
+ const other = rows.find(
+ (r) => r.kind === "leaf" && r.fullName === "origin/dev"
+ )
+ expect(tracking).toMatchObject({ isTracking: true })
+ expect(other).toMatchObject({ isTracking: false })
+ })
+})
+
+describe("buildBranchRows — operation grouping separators", () => {
+ it("inserts a separator after each groupEnd op (non-search)", () => {
+ const ops: BranchOperationMeta[] = [
+ { id: "pull", label: "Pull code" },
+ { id: "fetch", label: "Fetch", groupEnd: true },
+ { id: "commit", label: "Commit" },
+ ]
+ const rows = buildBranchRows(
+ baseInput({
+ operations: ops,
+ localNodes: localTree(["main"]),
+ localCount: 1,
+ })
+ )
+ // pull, fetch, SEP(groupEnd), commit, SEP(ops↔branches), then branches.
+ expect(rows.slice(0, 5).map(summarize)).toEqual([
+ "op:pull",
+ "op:fetch",
+ "sep",
+ "op:commit",
+ "sep",
+ ])
+ })
+
+ it("omits group separators while searching", () => {
+ const ops: BranchOperationMeta[] = [
+ { id: "pull", label: "Pull code" },
+ { id: "fetch", label: "Fetch pull", groupEnd: true },
+ ]
+ const rows = buildBranchRows(baseInput({ operations: ops, query: "pull" }))
+ expect(rows.map(summarize)).toEqual(["op:pull", "op:fetch"])
+ })
+})
+
+describe("buildBranchRows — search mode", () => {
+ it("flattens matched leaves under section headers, dropping groups", () => {
+ const rows = buildBranchRows(
+ baseInput({
+ localNodes: localTree(LOCAL),
+ localCount: 4,
+ query: "feature",
+ })
+ )
+ // "feature" matches no operation label, so no ops block and no separator.
+ expect(rows.map(summarize)).toEqual([
+ "section:local(2)+",
+ "leaf:feature/auth/login@1",
+ "leaf:feature/auth/logout@1",
+ ])
+ })
+
+ it("filters operations by label and omits the branch side when nothing matches", () => {
+ const rows = buildBranchRows(
+ baseInput({ localNodes: localTree(LOCAL), localCount: 4, query: "push" })
+ )
+ expect(rows.map(summarize)).toEqual(["op:push"])
+ })
+
+ it("keeps the separator when both an operation and branches match", () => {
+ const rows = buildBranchRows(
+ baseInput({ localNodes: localTree(LOCAL), localCount: 4, query: "e" })
+ )
+ // "Pull code" matches "e"; "Push..." does not. "main" has no "e".
+ expect(rows.map(summarize)).toEqual([
+ "op:pull",
+ "sep",
+ "section:local(3)+",
+ "leaf:feature/auth/login@1",
+ "leaf:feature/auth/logout@1",
+ "leaf:release/1.0@1",
+ ])
+ })
+})
+
+describe("buildBranchRows — multiple remotes", () => {
+ it("nests each remote as a wrapper group one level deeper", () => {
+ const remoteSections = buildRemoteBranchSections([
+ "origin/main",
+ "upstream/main",
+ "origin/dev",
+ ])
+ const rows = buildBranchRows(
+ baseInput({ operations: [], remoteSections, remoteCount: 3 })
+ )
+ expect(rows.map(summarize)).toEqual([
+ "section:local(0)+",
+ "empty:local",
+ "section:remote(3)+",
+ "group:origin@1+",
+ "leaf:origin/dev@2",
+ "leaf:origin/main@2",
+ "group:upstream@1+",
+ "leaf:upstream/main@2",
+ ])
+ })
+
+ it("collapsing a remote wrapper hides just that remote's branches", () => {
+ const remoteSections = buildRemoteBranchSections([
+ "origin/main",
+ "upstream/main",
+ ])
+ const originKey = remoteSections.find((s) => s.remoteName === "origin")!.key
+ const rows = buildBranchRows(
+ baseInput({
+ operations: [],
+ remoteSections,
+ remoteCount: 2,
+ collapsed: new Set([originKey]),
+ })
+ )
+ expect(rows.map(summarize)).toEqual([
+ "section:local(0)+",
+ "empty:local",
+ "section:remote(2)+",
+ "group:origin@1-",
+ "group:upstream@1+",
+ "leaf:upstream/main@2",
+ ])
+ })
+})
+
+describe("isNavigableRow", () => {
+ it("skips separators and empty rows, keeps everything else", () => {
+ expect(isNavigableRow({ kind: "separator", key: "s" })).toBe(false)
+ expect(isNavigableRow({ kind: "empty", key: "e", scope: "local" })).toBe(
+ false
+ )
+ expect(
+ isNavigableRow({
+ kind: "operation",
+ key: "o",
+ opId: "pull",
+ label: "Pull",
+ destructive: false,
+ })
+ ).toBe(true)
+ })
+})
diff --git a/src/lib/branch-selector-rows.ts b/src/lib/branch-selector-rows.ts
new file mode 100644
index 000000000..51ae02f83
--- /dev/null
+++ b/src/lib/branch-selector-rows.ts
@@ -0,0 +1,342 @@
+/**
+ * Flat, virtualization-ready row model for the branch selector popup.
+ *
+ * The rich branch selector (`BranchDropdown`) renders operations (pull / fetch /
+ * commit / push / new branch / worktree / stash / manage remotes) AND the full
+ * local+remote branch tree as ONE searchable, virtualized, flat list — mirroring
+ * the model picker's `flattenModelGroups` + `ModelOptionList` split. This module
+ * is the pure half: it flattens the prefix-grouped {@link BranchTreeNode} trees
+ * (from `@/lib/branch-tree`) plus the operation metadata into a linear
+ * `BranchRow[]` the renderer maps 1:1 to virtua rows.
+ *
+ * Deliberately pure — no React, no callbacks, no icons, no i18n. The renderer
+ * resolves icons/handlers by `kind`/`opId` and builds every translated string
+ * (section headers by `scope`+`count`), so all display concerns stay out of
+ * here and the flattening logic is unit-testable in isolation.
+ */
+
+import { sectionKey } from "@/lib/branch-tree"
+import type {
+ BranchTreeLeaf,
+ BranchTreeNode,
+ RemoteBranchSection,
+} from "@/lib/branch-tree"
+
+/** Container-supplied operation, resolved to icon + handler by the renderer. */
+export interface BranchOperationMeta {
+ id: string
+ /** Already-translated label — the ONLY string search matches operations on. */
+ label: string
+ destructive?: boolean
+ /** Emit a separator after this op (non-search) to visually group operations. */
+ groupEnd?: boolean
+}
+
+export type BranchLeafAction =
+ | "switch"
+ | "merge"
+ | "rebase"
+ | "delete"
+ | "deleteRemote"
+
+export type BranchRow =
+ | {
+ kind: "operation"
+ key: string
+ opId: string
+ label: string
+ destructive: boolean
+ }
+ | { kind: "separator"; key: string }
+ | {
+ kind: "section"
+ key: string
+ scope: "local" | "remote"
+ count: number
+ expanded: boolean
+ }
+ | {
+ kind: "group"
+ key: string
+ depth: number
+ label: string
+ count: number
+ expanded: boolean
+ }
+ | {
+ kind: "leaf"
+ key: string
+ depth: number
+ fullName: string
+ label: string
+ isRemote: boolean
+ isCurrent: boolean
+ isTracking: boolean
+ isWorktree: boolean
+ }
+ | { kind: "empty"; key: string; scope: "local" | "remote" }
+
+export interface BuildBranchRowsInput {
+ operations: BranchOperationMeta[]
+ localNodes: BranchTreeNode[]
+ remoteSections: RemoteBranchSection[]
+ /** Total local branch count (for the section header's "(n)"). */
+ localCount: number
+ /** Total remote branch count (for the section header's "(n)"). */
+ remoteCount: number
+ /** Current branch ref (marks the current leaf, suppresses its actions). */
+ branch: string | null
+ /** Branch names checked out in a worktree — leaf gets the worktree icon. */
+ worktreeBranchSet: Set
+ /** Group/section keys the user has collapsed (default-expanded = absent). */
+ collapsed: Set
+ /** Search query; when non-empty the tree flattens to matched leaves. */
+ query: string
+}
+
+const localSectionKey = sectionKey("local")
+const remoteSectionKey = sectionKey("remote")
+
+interface LeafContext {
+ branch: string | null
+ worktreeBranchSet: Set
+ collapsed: Set
+}
+
+/** Strip a remote leaf's `/` prefix (local leaves are returned as-is). */
+function localName(fullName: string, isRemote: boolean): string {
+ return isRemote ? fullName.replace(/^[^/]+\//, "") : fullName
+}
+
+/**
+ * Emit a single leaf row. Per-branch actions (switch/merge/rebase/delete) are
+ * NOT rows — the renderer shows them in a right-side bubble when a leaf is
+ * clicked (`isTracking` there hides delete for the tracked remote branch).
+ */
+function emitLeaf(
+ out: BranchRow[],
+ leaf: BranchTreeLeaf,
+ depth: number,
+ isRemote: boolean,
+ ctx: LeafContext
+): void {
+ const b = leaf.fullName
+ const isCurrent = b === ctx.branch
+ const isTracking =
+ isRemote && !!ctx.branch && localName(b, true) === ctx.branch
+ const isWorktree = ctx.worktreeBranchSet.has(localName(b, isRemote))
+
+ out.push({
+ kind: "leaf",
+ key: leaf.key,
+ depth,
+ fullName: b,
+ label: leaf.label,
+ isRemote,
+ isCurrent,
+ isTracking,
+ isWorktree,
+ })
+}
+
+/** Recursively flatten a prefix tree, descending only expanded groups. */
+function emitTree(
+ out: BranchRow[],
+ nodes: BranchTreeNode[],
+ depth: number,
+ isRemote: boolean,
+ ctx: LeafContext
+): void {
+ for (const node of nodes) {
+ if (node.type === "leaf") {
+ emitLeaf(out, node, depth, isRemote, ctx)
+ continue
+ }
+ const expanded = !ctx.collapsed.has(node.key)
+ out.push({
+ kind: "group",
+ key: node.key,
+ depth,
+ label: node.label,
+ count: node.count,
+ expanded,
+ })
+ if (expanded) emitTree(out, node.children, depth + 1, isRemote, ctx)
+ }
+}
+
+/** All leaf descendants of `nodes`, in render order. */
+function collectLeaves(nodes: BranchTreeNode[]): BranchTreeLeaf[] {
+ const leaves: BranchTreeLeaf[] = []
+ const walk = (list: BranchTreeNode[]) => {
+ for (const node of list) {
+ if (node.type === "leaf") leaves.push(node)
+ else walk(node.children)
+ }
+ }
+ walk(nodes)
+ return leaves
+}
+
+function matchesLeaf(leaf: BranchTreeLeaf, q: string): boolean {
+ return (
+ leaf.fullName.toLowerCase().includes(q) ||
+ leaf.label.toLowerCase().includes(q)
+ )
+}
+
+/**
+ * Flatten operations + branch trees into a single linear row list.
+ *
+ * - Empty query: operations block → separator → Local section (its prefix tree,
+ * descending only expanded groups) → Remote section (single-remote strips the
+ * wrapper; multi-remote nests each remote as a group). Sections default open.
+ * - Non-empty query: operations whose label matches → separator → matched local
+ * leaves and matched remote leaves, flat under their section headers (groups
+ * dropped, collapse state ignored); empty sections omitted.
+ *
+ * Indentation depth: operations flat; a section header is depth 0; its children
+ * are depth 1 (and deeper per nesting).
+ */
+export function buildBranchRows(input: BuildBranchRowsInput): BranchRow[] {
+ const {
+ operations,
+ localNodes,
+ remoteSections,
+ localCount,
+ remoteCount,
+ branch,
+ worktreeBranchSet,
+ collapsed,
+ query,
+ } = input
+
+ const q = query.trim().toLowerCase()
+ const searching = q.length > 0
+ const ctx: LeafContext = {
+ branch,
+ worktreeBranchSet,
+ collapsed,
+ }
+
+ const rows: BranchRow[] = []
+
+ // --- Operations ------------------------------------------------------------
+ // Grouped by a separator after each `groupEnd` op (non-search only) to mirror
+ // the old menu's pull/fetch | commit/push | … blocks.
+ for (const op of operations) {
+ if (searching && !op.label.toLowerCase().includes(q)) continue
+ rows.push({
+ kind: "operation",
+ key: `op:${op.id}`,
+ opId: op.id,
+ label: op.label,
+ destructive: op.destructive ?? false,
+ })
+ if (!searching && op.groupEnd) {
+ rows.push({ kind: "separator", key: `sep:op:${op.id}` })
+ }
+ }
+ const hasOperations = rows.some((row) => row.kind === "operation")
+
+ // --- Branches --------------------------------------------------------------
+ const branchRows: BranchRow[] = []
+
+ if (searching) {
+ const localMatches = collectLeaves(localNodes).filter((l) =>
+ matchesLeaf(l, q)
+ )
+ if (localMatches.length > 0) {
+ branchRows.push({
+ kind: "section",
+ key: localSectionKey,
+ scope: "local",
+ count: localMatches.length,
+ expanded: true,
+ })
+ for (const leaf of localMatches) emitLeaf(branchRows, leaf, 1, false, ctx)
+ }
+
+ const remoteMatches: BranchTreeLeaf[] = []
+ for (const section of remoteSections) {
+ for (const leaf of collectLeaves(section.nodes)) {
+ if (matchesLeaf(leaf, q)) remoteMatches.push(leaf)
+ }
+ }
+ if (remoteMatches.length > 0) {
+ branchRows.push({
+ kind: "section",
+ key: remoteSectionKey,
+ scope: "remote",
+ count: remoteMatches.length,
+ expanded: true,
+ })
+ for (const leaf of remoteMatches) emitLeaf(branchRows, leaf, 1, true, ctx)
+ }
+ } else {
+ // Local section
+ const localExpanded = !collapsed.has(localSectionKey)
+ branchRows.push({
+ kind: "section",
+ key: localSectionKey,
+ scope: "local",
+ count: localCount,
+ expanded: localExpanded,
+ })
+ if (localExpanded) {
+ if (localNodes.length === 0) {
+ branchRows.push({ kind: "empty", key: "empty:local", scope: "local" })
+ } else {
+ emitTree(branchRows, localNodes, 1, false, ctx)
+ }
+ }
+
+ // Remote section
+ const remoteExpanded = !collapsed.has(remoteSectionKey)
+ branchRows.push({
+ kind: "section",
+ key: remoteSectionKey,
+ scope: "remote",
+ count: remoteCount,
+ expanded: remoteExpanded,
+ })
+ if (remoteExpanded) {
+ if (remoteCount === 0) {
+ branchRows.push({ kind: "empty", key: "empty:remote", scope: "remote" })
+ } else {
+ for (const section of remoteSections) {
+ if (section.remoteName == null) {
+ emitTree(branchRows, section.nodes, 1, true, ctx)
+ continue
+ }
+ // Multiple remotes: each remote is a wrapper group toggled by its
+ // own section key, its branches nested one level deeper.
+ const wrapperExpanded = !collapsed.has(section.key)
+ branchRows.push({
+ kind: "group",
+ key: section.key,
+ depth: 1,
+ label: section.remoteName,
+ count: section.count,
+ expanded: wrapperExpanded,
+ })
+ if (wrapperExpanded) {
+ emitTree(branchRows, section.nodes, 2, true, ctx)
+ }
+ }
+ }
+ }
+ }
+
+ if (hasOperations && branchRows.length > 0) {
+ rows.push({ kind: "separator", key: "sep:ops-branches" })
+ }
+ rows.push(...branchRows)
+
+ return rows
+}
+
+/** Row kinds the keyboard cursor can land on (skips separators + empty rows). */
+export function isNavigableRow(row: BranchRow): boolean {
+ return row.kind !== "separator" && row.kind !== "empty"
+}
diff --git a/src/lib/branch-tree.test.ts b/src/lib/branch-tree.test.ts
index 83e22db59..9cf63105e 100644
--- a/src/lib/branch-tree.test.ts
+++ b/src/lib/branch-tree.test.ts
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"
import {
buildBranchTree,
buildRemoteBranchSections,
+ collectGroupKeys,
containsBranch,
expandedKeysForBranch,
localBranchItems,
@@ -288,3 +289,41 @@ describe("sectionKey", () => {
expect(sectionKey("remote:origin")).toBe("s remote:origin")
})
})
+
+describe("collectGroupKeys", () => {
+ it("collects every prefix-group key, descending nested groups in render order", () => {
+ const nodes = buildBranchTree(
+ localBranchItems([
+ "feat/auth/login",
+ "feat/auth/logout",
+ "feat/pay",
+ "main",
+ ]),
+ "local"
+ )
+ expect(collectGroupKeys(nodes)).toEqual([
+ "g local feat",
+ "g local feat/auth",
+ ])
+ })
+
+ it("returns [] when there are no surviving prefix groups", () => {
+ // Distinct top-level branches + a single collapsed chain — all leaves.
+ const nodes = buildBranchTree(
+ localBranchItems(["main", "dev", "a/b/c"]),
+ "local"
+ )
+ expect(collectGroupKeys(nodes)).toEqual([])
+ })
+
+ it("collects tree-internal groups but not the multi-remote wrapper key", () => {
+ const [origin] = buildRemoteBranchSections([
+ "origin/feat/a",
+ "origin/feat/b",
+ "upstream/main",
+ ])
+ // Only the in-tree "feat/" group — the wrapper (section.key) lives outside.
+ expect(collectGroupKeys(origin.nodes)).toEqual(["g remote:origin feat"])
+ expect(collectGroupKeys(origin.nodes)).not.toContain(origin.key)
+ })
+})
diff --git a/src/lib/branch-tree.ts b/src/lib/branch-tree.ts
index 66fed8d05..f46e4a0a0 100644
--- a/src/lib/branch-tree.ts
+++ b/src/lib/branch-tree.ts
@@ -233,6 +233,26 @@ export function expandedKeysForBranch(
return [...path]
}
+/**
+ * The expansion keys of every prefix group in `nodes`, in render order. Used to
+ * seed the branch selector's default-collapsed set so prefix groups start
+ * folded while their section stays open. Sections and the multi-remote wrapper
+ * are keyed outside the tree, so they're never included here.
+ */
+export function collectGroupKeys(nodes: BranchTreeNode[]): string[] {
+ const keys: string[] = []
+ const walk = (current: BranchTreeNode[]) => {
+ for (const node of current) {
+ if (node.type === "group") {
+ keys.push(node.key)
+ walk(node.children)
+ }
+ }
+ }
+ walk(nodes)
+ return keys
+}
+
/**
* Bucket remote branches by remote name and build a tree per remote. Mirrors the
* existing UX: a single remote strips its prefix with no wrapper level; multiple
diff --git a/src/lib/delegation-status.ts b/src/lib/delegation-status.ts
index 76eb5b23e..7a55a9770 100644
--- a/src/lib/delegation-status.ts
+++ b/src/lib/delegation-status.ts
@@ -535,9 +535,29 @@ export function buildDelegationTaskRows(
// gets a (spinner) row immediately instead of all-but-the-first vanishing
// until the batch resolves.
const count = Math.max(reports.length, inputIds.length)
+ const inFlight =
+ poll.state === "input-available" || poll.state === "input-streaming"
for (let i = 0; i < count; i++) {
const report = reports[i] ?? EMPTY_STATUS_REPORT
const taskId = report.taskId ?? inputIds[i] ?? null
+ // Drop an in-flight poll that carries no identity AND nothing to show yet.
+ // claude-agent-acp ships an arg-less initial `tool_call` (rawInput `{}`)
+ // and only fills the real `task_ids` on a later update — which, for a long
+ // `wait_ms` status check, lands near completion — so a poll that is still
+ // in flight often parses to this empty, id-less report. Each such re-poll
+ // would otherwise become its own anonymous "waiting for a task's result"
+ // row, stacking identical duplicates of the same wait. It folds into its
+ // task's row the moment an id or report arrives, and the settled transcript
+ // (whose polls always carry the id) is unaffected. A settled id-less report
+ // — a real interim note or terminal status — still gets its own row below.
+ if (
+ taskId == null &&
+ inFlight &&
+ report.status == null &&
+ (report.text == null || report.text.trim() === "")
+ ) {
+ continue
+ }
const key = taskId ?? `__unattributed__:${poll.toolCallId}:${i}`
let entry = byKey.get(key)
if (!entry) {
diff --git a/src/lib/fuzzy-text-match.test.ts b/src/lib/fuzzy-text-match.test.ts
new file mode 100644
index 000000000..98ac39908
--- /dev/null
+++ b/src/lib/fuzzy-text-match.test.ts
@@ -0,0 +1,128 @@
+import { describe, expect, it } from "vitest"
+import {
+ rankByTextMatch,
+ scoreTextMatch,
+ subsequenceFirstIndex,
+} from "@/lib/fuzzy-text-match"
+
+describe("subsequenceFirstIndex", () => {
+ it("matches contiguous and gapped subsequences", () => {
+ expect(subsequenceFirstIndex("bmadhelp", "bmad-help")).toBe(0)
+ expect(subsequenceFirstIndex("bmhp", "bmad-help")).toBe(0)
+ expect(subsequenceFirstIndex("help", "bmad-help")).toBe(5)
+ })
+
+ it("returns -1 when a character is missing", () => {
+ expect(subsequenceFirstIndex("bmadx", "bmad-help")).toBe(-1)
+ })
+})
+
+describe("scoreTextMatch tiers", () => {
+ it("orders exact > prefix > substring > subsequence", () => {
+ const q = "help"
+ const exact = scoreTextMatch(q, "help")!
+ const prefix = scoreTextMatch(q, "help-me")!
+ const substring = scoreTextMatch(q, "bmad-help")!
+ const subseq = scoreTextMatch("bmhp", "bmad-help")!
+
+ expect(exact).toBeGreaterThan(prefix)
+ expect(prefix).toBeGreaterThan(substring)
+ expect(scoreTextMatch("bmadh", "bmad-help")!).toBeLessThan(
+ scoreTextMatch("bmad-", "bmad-help")!
+ )
+ // subsequence still scores positive
+ expect(subseq).toBeGreaterThan(0)
+ })
+
+ it("returns null when nothing matches", () => {
+ expect(scoreTextMatch("zzz", "bmad-help")).toBeNull()
+ })
+})
+
+describe("rankByTextMatch", () => {
+ const cmds = [
+ { name: "bmad-help", description: "Show BMAD help" },
+ { name: "bmad-create-story", description: "Create a story" },
+ { name: "clear", description: "Clear the chat" },
+ ]
+
+ it("returns all items for an empty query", () => {
+ expect(rankByTextMatch("", cmds, (c) => c.name)).toEqual(cmds)
+ })
+
+ it("matches hyphenated names via subsequence (bmadhelp / bmhp)", () => {
+ const ranked = rankByTextMatch("bmadhelp", cmds, (c) => c.name)
+ expect(ranked.map((c) => c.name)).toEqual(["bmad-help"])
+
+ const short = rankByTextMatch("bmhp", cmds, (c) => c.name)
+ expect(short.map((c) => c.name)).toContain("bmad-help")
+ })
+
+ it("ranks exact > prefix > subsequence (shorter wins ties)", () => {
+ const items = [
+ { name: "batch" }, // subsequence b..h
+ { name: "bh-tool" }, // prefix
+ { name: "bh" }, // exact
+ { name: "bmad-help" }, // subsequence, longer than batch
+ ]
+ const ranked = rankByTextMatch("bh", items, (i) => i.name)
+ expect(ranked.map((i) => i.name)).toEqual([
+ "bh",
+ "bh-tool",
+ "batch",
+ "bmad-help",
+ ])
+ })
+
+ it("keeps any primary match above any secondary match", () => {
+ const items = [
+ { name: "zzz", tag: "bh" }, // secondary exact
+ { name: "batch", tag: "zzz" }, // primary subsequence (b..h)
+ ]
+ // Weakest primary (subsequence) still outranks strongest secondary (exact).
+ const ranked = rankByTextMatch(
+ "bh",
+ items,
+ (i) => i.name,
+ (i) => i.tag
+ )
+ expect(ranked.map((i) => i.name)).toEqual(["batch", "zzz"])
+ })
+
+ it("preserves input order for equally-scored matches (stable sort)", () => {
+ const items = [
+ { id: 1, name: "abc" },
+ { id: 2, name: "abc" },
+ { id: 3, name: "abc" },
+ ]
+ const ranked = rankByTextMatch("abc", items, (i) => i.name)
+ expect(ranked.map((i) => i.id)).toEqual([1, 2, 3])
+ })
+
+ it("skips items with empty primary text without matching", () => {
+ const items = [{ name: "" }, { name: "bmad-help" }]
+ const ranked = rankByTextMatch("bmad", items, (i) => i.name)
+ expect(ranked.map((i) => i.name)).toEqual(["bmad-help"])
+ })
+
+ it("falls back to secondary field below primary hits", () => {
+ const items = [
+ { name: "clear", description: "bmad helpers" },
+ { name: "bmad-help", description: "other" },
+ ]
+ const ranked = rankByTextMatch(
+ "bmad",
+ items,
+ (i) => i.name,
+ (i) => i.description
+ )
+ // name prefix/substring on bmad-help beats description-only on clear
+ expect(ranked[0].name).toBe("bmad-help")
+ expect(ranked.map((i) => i.name)).toContain("clear")
+ })
+
+ it("is case-insensitive", () => {
+ const ranked = rankByTextMatch("BMADHELP", cmds, (c) => c.name)
+ expect(ranked.map((c) => c.name)).toEqual(["bmad-help"])
+ })
+})
diff --git a/src/lib/fuzzy-text-match.ts b/src/lib/fuzzy-text-match.ts
new file mode 100644
index 000000000..0f535e509
--- /dev/null
+++ b/src/lib/fuzzy-text-match.ts
@@ -0,0 +1,98 @@
+/**
+ * Ranked text matcher for slash-command / skill autocomplete.
+ * No external fuzzy dependency — tiers are exact → prefix → substring →
+ * subsequence so short inputs like `bmhp` can still hit `bmad-help`.
+ *
+ * Same scoring shape as `file-search-match.ts` (tier dominates; earlier
+ * match position and shorter candidate win within a tier).
+ */
+
+const TIER_BASE = 100_000_000
+
+const TIER_EXACT = 6
+const TIER_PREFIX = 5
+const TIER_SUBSTRING = 4
+const TIER_SUBSEQUENCE = 3
+
+function tierScore(tier: number, position: number, length: number): number {
+ return (
+ tier * TIER_BASE - Math.min(position, 9_999) * 1_000 - Math.min(length, 999)
+ )
+}
+
+/**
+ * Index of the first matched character if every char of `query` appears in `s`
+ * in order (a subsequence), else -1. `query` must be non-empty.
+ */
+export function subsequenceFirstIndex(query: string, s: string): number {
+ let qi = 0
+ let firstIdx = -1
+ for (let si = 0; si < s.length && qi < query.length; si++) {
+ if (s[si] === query[qi]) {
+ if (qi === 0) firstIdx = si
+ qi++
+ }
+ }
+ return qi === query.length ? firstIdx : -1
+}
+
+/**
+ * Score one already-lowercased string against an already-lowercased,
+ * non-empty `query`. Higher is better; `null` means no match.
+ */
+export function scoreTextMatch(query: string, text: string): number | null {
+ if (!query) return null
+
+ if (text === query) {
+ return tierScore(TIER_EXACT, 0, text.length)
+ }
+ if (text.startsWith(query)) {
+ return tierScore(TIER_PREFIX, 0, text.length)
+ }
+ const idx = text.indexOf(query)
+ if (idx !== -1) {
+ return tierScore(TIER_SUBSTRING, idx, text.length)
+ }
+ const sub = subsequenceFirstIndex(query, text)
+ if (sub !== -1) {
+ return tierScore(TIER_SUBSEQUENCE, sub, text.length)
+ }
+ return null
+}
+
+/**
+ * Filter + rank `items` by primary text, with optional secondary field fallback
+ * (e.g. description / skill id). Empty query returns items unchanged (original
+ * order). Secondary hits always rank below any primary hit.
+ */
+export function rankByTextMatch(
+ query: string,
+ items: readonly T[],
+ getPrimary: (item: T) => string,
+ getSecondary?: (item: T) => string | undefined | null
+): T[] {
+ const q = query.trim().toLowerCase()
+ if (!q) return items.slice()
+
+ // Drop secondary below every primary tier so a weak name subsequence still
+ // beats a perfect description / id match (mirrors previous name-first lists).
+ const secondaryOffset = TIER_EXACT * TIER_BASE
+
+ const scored: { item: T; score: number }[] = []
+ for (const item of items) {
+ const primary = getPrimary(item).toLowerCase()
+ let score = scoreTextMatch(q, primary)
+ if (score === null && getSecondary) {
+ const secondary = getSecondary(item)?.toLowerCase()
+ if (secondary) {
+ const sec = scoreTextMatch(q, secondary)
+ if (sec !== null) score = sec - secondaryOffset
+ }
+ }
+ if (score !== null) scored.push({ item, score })
+ }
+
+ // ES2019 stable sort keeps original order for equal scores.
+ scored.sort((a, b) => b.score - a.score)
+ return scored.map((s) => s.item)
+}