diff --git a/src/renderer/src/components/cmd-j/palette-section-render-cap.test.ts b/src/renderer/src/components/cmd-j/palette-section-render-cap.test.ts
index 810c44b07f0..eb19e89b99f 100644
--- a/src/renderer/src/components/cmd-j/palette-section-render-cap.test.ts
+++ b/src/renderer/src/components/cmd-j/palette-section-render-cap.test.ts
@@ -1,5 +1,13 @@
import { describe, expect, it } from 'vitest'
-import { capPaletteSection, PALETTE_SECTION_RENDER_CAP } from './palette-section-render-cap'
+import {
+ capPaletteSection,
+ layoutMultiPrimaryPaletteSections,
+ orderMultiPrimaryPaletteItems,
+ PALETTE_SECTION_RENDER_CAP,
+ softSplitPaletteSection,
+ TYPED_QUERY_LEADING_PREVIEW,
+ TYPED_QUERY_TRAILING_FLOOR
+} from './palette-section-render-cap'
const range = (count: number): number[] => Array.from({ length: count }, (_, index) => index)
@@ -37,3 +45,87 @@ describe('capPaletteSection', () => {
expect(capPaletteSection(range(3), 0)).toEqual({ visible: [], overflowCount: 3 })
})
})
+
+describe('softSplitPaletteSection', () => {
+ it('splits under the hard cap and reports everything after the preview', () => {
+ const split = softSplitPaletteSection(range(12), TYPED_QUERY_LEADING_PREVIEW)
+
+ expect(split.preview).toEqual(range(6))
+ expect(split.rest).toEqual([6, 7, 8, 9, 10, 11])
+ expect(split.moreCount).toBe(6)
+ })
+
+ it('includes hard-cap overflow in moreCount without rendering it', () => {
+ const split = softSplitPaletteSection(range(80), TYPED_QUERY_LEADING_PREVIEW)
+
+ expect(split.preview).toHaveLength(TYPED_QUERY_LEADING_PREVIEW)
+ expect(split.rest).toHaveLength(PALETTE_SECTION_RENDER_CAP - TYPED_QUERY_LEADING_PREVIEW)
+ expect(split.moreCount).toBe(80 - TYPED_QUERY_LEADING_PREVIEW)
+ })
+
+ it('keeps short sections intact with no remainder', () => {
+ const items = range(3)
+ const split = softSplitPaletteSection(items, TYPED_QUERY_LEADING_PREVIEW)
+
+ expect(split.preview).toEqual(items)
+ expect(split.rest).toEqual([])
+ expect(split.moreCount).toBe(0)
+ })
+})
+
+describe('layoutMultiPrimaryPaletteSections', () => {
+ it('puts trailing floor before leading rest so worktrees stay early', () => {
+ const layout = layoutMultiPrimaryPaletteSections({
+ leadingItems: range(12),
+ trailingItems: range(5).map((n) => n + 100)
+ })
+
+ expect(layout.leadingPreview).toEqual(range(TYPED_QUERY_LEADING_PREVIEW))
+ expect(layout.leadingMoreCount).toBe(6)
+ expect(layout.trailingFloor).toEqual([100, 101, 102])
+ expect(layout.trailingFloor).toHaveLength(TYPED_QUERY_TRAILING_FLOOR)
+ expect(layout.trailingRest).toEqual([103, 104])
+ expect(layout.leadingRest).toEqual([6, 7, 8, 9, 10, 11])
+ expect(layout.trailingMoreCount).toBe(2)
+ expect(layout.trailingHardOverflowCount).toBe(0)
+ })
+
+ it('shows all trailing rows in the floor when the section is small', () => {
+ const layout = layoutMultiPrimaryPaletteSections({
+ leadingItems: range(10),
+ trailingItems: [200, 201]
+ })
+
+ expect(layout.trailingFloor).toEqual([200, 201])
+ expect(layout.trailingRest).toEqual([])
+ expect(layout.trailingMoreCount).toBe(0)
+ expect(layout.trailingHardOverflowCount).toBe(0)
+ })
+
+ it('reports trailing hard-cap overflow without counting scrollable rest', () => {
+ const layout = layoutMultiPrimaryPaletteSections({
+ leadingItems: range(12),
+ trailingItems: range(80).map((n) => n + 1000)
+ })
+
+ expect(layout.trailingFloor).toHaveLength(TYPED_QUERY_TRAILING_FLOOR)
+ expect(layout.trailingRest).toHaveLength(
+ PALETTE_SECTION_RENDER_CAP - TYPED_QUERY_TRAILING_FLOOR
+ )
+ expect(layout.trailingMoreCount).toBe(80 - TYPED_QUERY_TRAILING_FLOOR)
+ expect(layout.trailingHardOverflowCount).toBe(80 - PALETTE_SECTION_RENDER_CAP)
+ })
+})
+
+describe('orderMultiPrimaryPaletteItems', () => {
+ it('interleaves floor before leading rest', () => {
+ const layout = layoutMultiPrimaryPaletteSections({
+ leadingItems: range(8),
+ trailingItems: range(5).map((n) => n + 100)
+ })
+
+ expect(orderMultiPrimaryPaletteItems(layout)).toEqual([
+ 0, 1, 2, 3, 4, 5, 100, 101, 102, 6, 7, 103, 104
+ ])
+ })
+})
diff --git a/src/renderer/src/components/cmd-j/palette-section-render-cap.ts b/src/renderer/src/components/cmd-j/palette-section-render-cap.ts
index 1e2f8efc1f9..334e2c8c1ff 100644
--- a/src/renderer/src/components/cmd-j/palette-section-render-cap.ts
+++ b/src/renderer/src/components/cmd-j/palette-section-render-cap.ts
@@ -8,6 +8,16 @@
*/
export const PALETTE_SECTION_RENDER_CAP = 50
+/**
+ * First-screen soft split for typed queries when open tabs and worktrees both
+ * match. Leading section shows this many rows, then a non-selectable hint, then
+ * the trailing section floor so worktrees (or tabs) stay above the fold.
+ */
+export const TYPED_QUERY_LEADING_PREVIEW = 6
+
+/** Min rows for the non-leading primary section (tabs ↔ worktrees) in the first screen. */
+export const TYPED_QUERY_TRAILING_FLOOR = 3
+
export type CappedPaletteSection
= {
visible: readonly T[]
overflowCount: number
@@ -22,3 +32,88 @@ export function capPaletteSection(
}
return { visible: items.slice(0, cap), overflowCount: items.length - cap }
}
+
+/**
+ * Soft-split a hard-capped section into first-screen preview + remainder.
+ * `moreCount` is everything after the preview (including rows past the hard cap)
+ * so one overflow hint covers both scrollable rest and “keep typing” remainder.
+ */
+export type SoftSplitSection = {
+ preview: readonly T[]
+ rest: readonly T[]
+ moreCount: number
+}
+
+export function softSplitPaletteSection(
+ items: readonly T[],
+ previewCount: number,
+ hardCap: number = PALETTE_SECTION_RENDER_CAP
+): SoftSplitSection {
+ const capped = capPaletteSection(items, hardCap)
+ const previewSize = Math.max(0, Math.min(previewCount, capped.visible.length))
+ return {
+ preview: capped.visible.slice(0, previewSize),
+ rest: capped.visible.slice(previewSize),
+ moreCount: Math.max(0, items.length - previewSize)
+ }
+}
+
+/**
+ * Layout when a typed query hits both open tabs and worktrees.
+ * Leading section gets a soft preview; trailing primary gets an early floor so
+ * it is not buried under ~50 leading rows. Remaining rows of both sections
+ * follow (still under the hard cap). Projects/middle stay after both primaries.
+ *
+ * `leadingMoreCount` is the mid-list soft hint (rest + hard-cap overflow).
+ * `trailingHardOverflowCount` is only rows past the hard cap — trailing rest is
+ * already rendered, so a soft “more” would double-count scrollable rows.
+ */
+export type MultiPrimarySectionLayout = {
+ leadingPreview: readonly T[]
+ leadingRest: readonly T[]
+ leadingMoreCount: number
+ trailingFloor: readonly T[]
+ trailingRest: readonly T[]
+ trailingMoreCount: number
+ trailingHardOverflowCount: number
+}
+
+export function layoutMultiPrimaryPaletteSections({
+ leadingItems,
+ trailingItems,
+ leadingPreviewCount = TYPED_QUERY_LEADING_PREVIEW,
+ trailingFloorCount = TYPED_QUERY_TRAILING_FLOOR,
+ hardCap = PALETTE_SECTION_RENDER_CAP
+}: {
+ leadingItems: readonly T[]
+ trailingItems: readonly T[]
+ leadingPreviewCount?: number
+ trailingFloorCount?: number
+ hardCap?: number
+}): MultiPrimarySectionLayout {
+ const leading = softSplitPaletteSection(leadingItems, leadingPreviewCount, hardCap)
+ const trailing = softSplitPaletteSection(trailingItems, trailingFloorCount, hardCap)
+ return {
+ leadingPreview: leading.preview,
+ leadingRest: leading.rest,
+ leadingMoreCount: leading.moreCount,
+ trailingFloor: trailing.preview,
+ trailingRest: trailing.rest,
+ trailingMoreCount: trailing.moreCount,
+ // Why: floor + rest already cover every rendered trailing row; only the
+ // hard-capped tail needs a “keep typing” hint after the section.
+ trailingHardOverflowCount: Math.max(0, trailing.moreCount - trailing.rest.length)
+ }
+}
+
+/** Selection/render order for the two interleaved primary sections. */
+export function orderMultiPrimaryPaletteItems(
+ layout: MultiPrimarySectionLayout
+): readonly T[] {
+ return [
+ ...layout.leadingPreview,
+ ...layout.trailingFloor,
+ ...layout.leadingRest,
+ ...layout.trailingRest
+ ]
+}
diff --git a/src/renderer/src/components/tab-bar/open-tab-search.test.ts b/src/renderer/src/components/tab-bar/open-tab-search.test.ts
index 6a27884d96b..1476bdaa3bb 100644
--- a/src/renderer/src/components/tab-bar/open-tab-search.test.ts
+++ b/src/renderer/src/components/tab-bar/open-tab-search.test.ts
@@ -58,7 +58,7 @@ function makeWorkspaceTab({
id,
title,
contentType = 'terminal',
- secondaryText = 'Terminal tab',
+ secondaryText = '',
secondarySearchTexts,
agentSnippets = [],
tabSortIndex = 0,
@@ -85,7 +85,7 @@ function makeWorkspaceTab({
title,
secondaryText,
titleSearchText: title,
- secondarySearchTexts: secondarySearchTexts ?? [secondaryText],
+ secondarySearchTexts: secondarySearchTexts ?? (secondaryText ? [secondaryText] : []),
agentMetadata: agentSnippets.length
? [{ paneKey: `${id}-pane`, textParts: [], snippetCandidates: agentSnippets }]
: [],
diff --git a/src/renderer/src/lib/cmd-j-match-relevance.test.ts b/src/renderer/src/lib/cmd-j-match-relevance.test.ts
index 36da3f66d0d..07711ac97bc 100644
--- a/src/renderer/src/lib/cmd-j-match-relevance.test.ts
+++ b/src/renderer/src/lib/cmd-j-match-relevance.test.ts
@@ -219,4 +219,17 @@ describe('getOpenTabMatchRelevance', () => {
)
expect(secondary).toBeLessThan(workspace)
})
+
+ it('ranks search-only type aliases as secondary-tier hits', () => {
+ const typeAlias = getOpenTabMatchRelevance(
+ makeOpenTab({
+ typeAliasMatch: { text: 'terminal tab', range: { start: 0, end: 8 } }
+ })
+ )
+ const titlePrefix = getOpenTabMatchRelevance(makeOpenTab({ titleRange: { start: 0, end: 4 } }))
+ const ambient = getOpenTabMatchRelevance(makeOpenTab({ worktreeRange: { start: 0, end: 4 } }))
+ expect(typeAlias).toBeLessThan(NO_MATCH_RELEVANCE)
+ expect(titlePrefix).toBeLessThan(typeAlias)
+ expect(typeAlias).toBeLessThan(ambient)
+ })
})
diff --git a/src/renderer/src/lib/cmd-j-match-relevance.ts b/src/renderer/src/lib/cmd-j-match-relevance.ts
index cfbc95163eb..44015d0d4f1 100644
--- a/src/renderer/src/lib/cmd-j-match-relevance.ts
+++ b/src/renderer/src/lib/cmd-j-match-relevance.ts
@@ -81,12 +81,23 @@ export type OpenTabRelevanceInput = {
repoRange: MatchRange | null
workspaceLabel?: string | null
workspaceRange?: MatchRange | null
+ /**
+ * Search-only type label match (e.g. "terminal tab" / "mobile emulator").
+ * Not rendered on the row — still needs a relevance field so section leadership
+ * and open-tab sort don't treat the hit as unmatched.
+ */
+ typeAliasMatch?: { text: string; range: MatchRange } | null
}
export function getOpenTabMatchRelevance(result: OpenTabRelevanceInput): number {
return scorePaletteRelevance([
{ text: result.title, range: result.titleRange, tier: 0 },
{ text: result.secondaryText, range: result.secondaryRange, tier: 1 },
+ {
+ text: result.typeAliasMatch?.text ?? '',
+ range: result.typeAliasMatch?.range ?? null,
+ tier: 1
+ },
{
text: result.workspaceLabel ?? '',
range: result.workspaceRange ?? null,
diff --git a/src/renderer/src/lib/simulator-palette-search.test.ts b/src/renderer/src/lib/simulator-palette-search.test.ts
index cfc8aa4a720..ee74978b101 100644
--- a/src/renderer/src/lib/simulator-palette-search.test.ts
+++ b/src/renderer/src/lib/simulator-palette-search.test.ts
@@ -108,9 +108,17 @@ describe('simulator-palette-search', () => {
}
]
- expect(searchSimulatorTabs(entries, 'mobile')[0]?.secondaryRange).toEqual({ start: 0, end: 6 })
+ // Why no secondaryRange: type aliases match without a display secondary.
+ const mobileHit = searchSimulatorTabs(entries, 'mobile')[0]
+ expect(mobileHit?.secondaryText).toBe('')
+ expect(mobileHit?.secondaryRange).toBeNull()
+ expect(mobileHit?.typeAliasMatch).toEqual({
+ text: 'mobile emulator tab',
+ range: { start: 0, end: 6 }
+ })
expect(searchSimulatorTabs(entries, 'simulator')).toHaveLength(1)
expect(searchSimulatorTabs(entries, 'ios')).toHaveLength(1)
+ expect(searchSimulatorTabs(entries, 'emulator')).toHaveLength(1)
})
it('searches worktree and repo metadata', () => {
diff --git a/src/renderer/src/lib/simulator-palette-search.ts b/src/renderer/src/lib/simulator-palette-search.ts
index aded934662e..e458f62ae7f 100644
--- a/src/renderer/src/lib/simulator-palette-search.ts
+++ b/src/renderer/src/lib/simulator-palette-search.ts
@@ -24,6 +24,7 @@ export type SimulatorPaletteSearchResult = {
secondaryRange: MatchRange | null
repoRange: MatchRange | null
worktreeRange: MatchRange | null
+ typeAliasMatch?: { text: string; range: MatchRange } | null
isCurrentTab: boolean
isCurrentWorktree: boolean
score: number
@@ -33,6 +34,16 @@ type SimulatorPaletteActiveTabType = 'browser' | 'editor' | 'terminal' | 'simula
export const SIMULATOR_PALETTE_QUERY_MAX_BYTES = 2 * 1024
+// Why search-only: the row icon already says "emulator"; a fixed secondary label
+// crowds Cmd+J the same way "Terminal tab" did. Keep these strings matchable so
+// typing "mobile" / "simulator" still finds emulator tabs.
+const SIMULATOR_TYPE_SEARCH_ALIASES = [
+ 'mobile emulator tab',
+ 'mobile emulator',
+ 'ios simulator',
+ 'emulator'
+] as const
+
export function isSimulatorPaletteQueryTooLarge(
query: string,
maxBytes = SIMULATOR_PALETTE_QUERY_MAX_BYTES
@@ -180,7 +191,9 @@ export function searchSimulatorTabs(
for (const entry of entries) {
const title = entry.tab.label || 'Mobile Emulator'
- const secondaryText = 'Mobile Emulator tab'
+ // Why: type is already clear from the smartphone icon; a fixed label only
+ // crowds the row (and used to leave a bare "· ·" under width pressure).
+ const secondaryText = ''
// Why: a cleared display name leaves this undefined at runtime; findRange would throw.
const worktreeName = resolveWorktreeDisplayName(entry.worktree)
const baseResult = {
@@ -226,32 +239,28 @@ export function searchSimulatorTabs(
continue
}
- const secondaryRange = findRange(secondaryText, trimmedQuery)
- if (secondaryRange) {
- results.push({
- ...baseResult,
- titleRange: null,
- secondaryRange,
- repoRange: null,
- worktreeRange: null,
- score: scoreSimulatorTabMatch({
- fieldWeight: 20,
- matchIndex: secondaryRange.start,
- entry
- })
- })
- continue
+ let typeAliasHit: { text: string; range: MatchRange } | null = null
+ for (const alias of SIMULATOR_TYPE_SEARCH_ALIASES) {
+ const range = findRange(alias, trimmedQuery)
+ if (range) {
+ typeAliasHit = { text: alias, range }
+ break
+ }
}
-
- const aliasRange = findRange('ios simulator', trimmedQuery)
- if (aliasRange) {
+ if (typeAliasHit) {
results.push({
...baseResult,
titleRange: null,
+ // Why null: aliases are search keys only — nothing to highlight in the row.
secondaryRange: null,
repoRange: null,
worktreeRange: null,
- score: scoreSimulatorTabMatch({ fieldWeight: 24, matchIndex: aliasRange.start, entry })
+ typeAliasMatch: typeAliasHit,
+ score: scoreSimulatorTabMatch({
+ fieldWeight: 20,
+ matchIndex: typeAliasHit.range.start,
+ entry
+ })
})
continue
}
diff --git a/src/renderer/src/lib/workspace-tab-palette-activation.test.ts b/src/renderer/src/lib/workspace-tab-palette-activation.test.ts
index 101841524a4..5d0def8b8ba 100644
--- a/src/renderer/src/lib/workspace-tab-palette-activation.test.ts
+++ b/src/renderer/src/lib/workspace-tab-palette-activation.test.ts
@@ -103,7 +103,7 @@ function makeResult(
groupId: 'group-1',
contentType: 'terminal',
title: 'Terminal',
- secondaryText: 'Terminal tab',
+ secondaryText: '',
repoName: 'repo/orca',
worktreeName: 'Palette Worktree',
titleRange: null,
diff --git a/src/renderer/src/lib/workspace-tab-palette-results.ts b/src/renderer/src/lib/workspace-tab-palette-results.ts
index 9d829355a43..4d0e5dc8379 100644
--- a/src/renderer/src/lib/workspace-tab-palette-results.ts
+++ b/src/renderer/src/lib/workspace-tab-palette-results.ts
@@ -19,6 +19,7 @@ export type WorkspaceTabPaletteSearchResult = {
secondaryRange: MatchRange | null
repoRange: MatchRange | null
worktreeRange: MatchRange | null
+ typeAliasMatch?: { text: string; range: MatchRange } | null
isCurrentTab: boolean
isCurrentWorktree: boolean
score: number
@@ -185,6 +186,33 @@ export function searchWorkspaceTabs(
continue
}
+ // Why after display secondaries: path/file matches should beat bare type labels.
+ let typeAliasHit: { text: string; range: MatchRange } | null = null
+ for (const alias of entry.typeSearchAliases ?? []) {
+ const range = findRange(alias, trimmedQuery)
+ if (range) {
+ typeAliasHit = { text: alias, range }
+ break
+ }
+ }
+ if (typeAliasHit) {
+ results.push({
+ ...baseResult,
+ titleRange: null,
+ // Why null: aliases are search keys only — nothing to highlight in the row.
+ secondaryRange: null,
+ repoRange: null,
+ worktreeRange: null,
+ typeAliasMatch: typeAliasHit,
+ score: scoreWorkspaceTabMatch({
+ fieldWeight: 25,
+ matchIndex: typeAliasHit.range.start,
+ entry
+ })
+ })
+ continue
+ }
+
const agentMatch = getBestAgentSnippet(entry, trimmedQuery)
if (agentMatch) {
results.push({
diff --git a/src/renderer/src/lib/workspace-tab-palette-search.test.ts b/src/renderer/src/lib/workspace-tab-palette-search.test.ts
index 54bc1b26ecb..a2a7e147bc3 100644
--- a/src/renderer/src/lib/workspace-tab-palette-search.test.ts
+++ b/src/renderer/src/lib/workspace-tab-palette-search.test.ts
@@ -491,4 +491,34 @@ describe('workspace-tab-palette-search', () => {
worktreeRange: null
})
})
+
+ it('omits the fixed Terminal tab secondary on terminals', () => {
+ const entries = buildEntries()
+ const emptyQuery = searchWorkspaceTabs(entries, '')[0]
+ expect(emptyQuery).toMatchObject({
+ contentType: 'terminal',
+ secondaryText: '',
+ secondaryRange: null
+ })
+ expect(entries[0]?.secondarySearchTexts).toEqual([])
+ })
+
+ it('still finds terminals via type aliases without showing a secondary', () => {
+ const entries = buildEntries()
+ // Title is agent-named so the hit has to come from the type alias, not the title.
+ const renamed = entries.map((entry, index) =>
+ index === 0 ? { ...entry, title: 'Fix login race', titleSearchText: 'Fix login race' } : entry
+ )
+ const hit = searchWorkspaceTabs(renamed, 'terminal')[0]
+ expect(hit).toMatchObject({
+ contentType: 'terminal',
+ title: 'Fix login race',
+ secondaryText: '',
+ secondaryRange: null,
+ typeAliasMatch: {
+ text: 'terminal tab',
+ range: { start: 0, end: 8 }
+ }
+ })
+ })
})
diff --git a/src/renderer/src/lib/workspace-tab-palette-search.ts b/src/renderer/src/lib/workspace-tab-palette-search.ts
index b4f2c407274..1d037335fb4 100644
--- a/src/renderer/src/lib/workspace-tab-palette-search.ts
+++ b/src/renderer/src/lib/workspace-tab-palette-search.ts
@@ -33,11 +33,20 @@ export type SearchableWorkspaceTab = {
secondaryText: string
titleSearchText: string
secondarySearchTexts: string[]
+ /**
+ * Search-only type labels (e.g. "terminal tab"). Matched without writing into
+ * the row secondary — the content icon already conveys type.
+ */
+ typeSearchAliases?: readonly string[]
agentMetadata: AgentMetadata[]
isCurrentTab: boolean
isCurrentWorktree: boolean
}
+// Why search-only: the status/content icon already says "terminal"; a fixed
+// secondary crowds the row. Keep these matchable so typing "terminal" still finds them.
+export const TERMINAL_TYPE_SEARCH_ALIASES = ['terminal tab', 'terminal'] as const
+
type WorkspaceTabPaletteActiveTabType = 'browser' | 'editor' | 'terminal' | 'simulator'
export type BuildSearchableWorkspaceTabsOptions = WorkspaceTabAgentMetadataState & {
@@ -221,9 +230,12 @@ export function buildSearchableWorkspaceTabs({
entries.push({
...baseEntry,
title,
- secondaryText: 'Terminal tab',
+ // Why: type is already clear from the status/content icon; a fixed
+ // "Terminal tab" label only crowds the row (and used to leave a bare "· ·").
+ secondaryText: '',
titleSearchText: title,
- secondarySearchTexts: ['Terminal tab'],
+ secondarySearchTexts: [],
+ typeSearchAliases: TERMINAL_TYPE_SEARCH_ALIASES,
agentMetadata: collectAgentMetadataForTerminal({
terminalTabId: tab.entityId,
worktreeId: worktree.id,