diff --git a/src/actions/launch.ts b/src/actions/launch.ts index b3f89c5..db87844 100644 --- a/src/actions/launch.ts +++ b/src/actions/launch.ts @@ -15,9 +15,10 @@ export async function doLaunch() { return } - const items: { path: string; name: string; sessionId?: string; targetBranch?: string }[] = [] + type LaunchItem = { path: string; name: string; tabNum: number; sessionId?: string; targetBranch?: string } + const items: LaunchItem[] = [] - for (const path of app.selectedProjects) { + for (const [path, tabNum] of app.selectedProjects) { const project = app.projects.find(p => p.path === path) if (!project) continue const targetBranch = app.selectedBranches.get(path) @@ -27,7 +28,7 @@ export async function doLaunch() { project.sessionCount = project.sessions.length } const lastSessionId = project.sessions[0]?.id - items.push({ path, name: project.name, sessionId: lastSessionId, targetBranch: needsBranch ? targetBranch : undefined }) + items.push({ path, name: project.name, tabNum, sessionId: lastSessionId, targetBranch: needsBranch ? targetBranch : undefined }) } for (const project of app.projects) { @@ -36,41 +37,58 @@ export async function doLaunch() { if (app.selectedSessions.has(session.id)) { const targetBranch = app.selectedBranches.get(project.path) const needsBranch = targetBranch && targetBranch !== project.branch - items.push({ path: project.path, name: project.name, sessionId: session.id, targetBranch: needsBranch ? targetBranch : undefined }) + // Sessions without explicit tab number go to tab 1 + items.push({ path: project.path, name: project.name, tabNum: 1, sessionId: session.id, targetBranch: needsBranch ? targetBranch : undefined }) } } } if (items.length === 0) return - // Determine target tab: use active grid tab or create a new one - let targetTabId: number - if (app.viewMode === "grid" && app.directGrid && app.gridTabs.length > 0) { - targetTabId = app.directGrid.activeTabId - } else { - targetTabId = createNewGridTab() + // Group items by tab number + const byTab = new Map() + for (const item of items) { + if (!byTab.has(item.tabNum)) byTab.set(item.tabNum, []) + byTab.get(item.tabNum)!.push(item) } ensureGridView() - const termW = process.stdout.columns || 120 - const termH = process.stdout.rows || 40 - const totalPanes = items.length + (app.directGrid?.getTabPaneCount(targetTabId) || 0) - const cols = totalPanes <= 1 ? 1 : totalPanes <= 2 ? 2 : totalPanes <= 4 ? 2 : 3 - const rows = Math.ceil(totalPanes / cols) - const paneW = Math.max(Math.floor(termW / cols) - 2, 20) - const paneH = Math.max(Math.floor((termH - 2) / rows) - 4, 6) + // Launch each tab group into its own grid tab + for (const [tabNum, tabItems] of byTab) { + // Find existing grid tab for this number, or create one + let targetTabId: number + const existingTab = app.gridTabs.find(t => t.name === `Tab ${tabNum}`) + if (existingTab) { + targetTabId = existingTab.id + } else { + targetTabId = createNewGridTab() + // Rename to match the picker tab number + const tab = app.gridTabs.find(t => t.id === targetTabId) + if (tab) tab.name = `Tab ${tabNum}` + } - for (const item of items) { - const session = await createSession({ - projectPath: item.path, - projectName: item.name, - sessionId: item.sessionId, - targetBranch: item.targetBranch, - width: paneW, - height: paneH, - }) - await app.directGrid!.addPane(session, targetTabId) + const termW = process.stdout.columns || 120 + const termH = process.stdout.rows || 40 + const totalPanes = tabItems.length + (app.directGrid?.getTabPaneCount(targetTabId) || 0) + const cols = totalPanes <= 1 ? 1 : totalPanes <= 2 ? 2 : totalPanes <= 4 ? 2 : 3 + const rows = Math.ceil(totalPanes / cols) + const paneW = Math.max(Math.floor(termW / cols) - 2, 20) + const paneH = Math.max(Math.floor((termH - 2) / rows) - 4, 6) + + for (const item of tabItems) { + const session = await createSession({ + projectPath: item.path, + projectName: item.name, + sessionId: item.sessionId, + targetBranch: item.targetBranch, + width: paneW, + height: paneH, + }) + await app.directGrid!.addPane(session, targetTabId) + } + + switchToGridTab(targetTabId) } app.selectedProjects.clear() diff --git a/src/actions/launcher.ts b/src/actions/launcher.ts index 260a49a..29fab27 100644 --- a/src/actions/launcher.ts +++ b/src/actions/launcher.ts @@ -9,13 +9,13 @@ interface LaunchItem { export async function launchSelections( projects: Project[], - selectedProjects: Set, + selectedProjects: Map, selectedSessions: Set, selectedBranches: Map = new Map() ): Promise { const byProject = new Map() - for (const path of selectedProjects) { + for (const [path] of selectedProjects) { if (!byProject.has(path)) byProject.set(path, []) const targetBranch = selectedBranches.get(path) const project = projects.find(p => p.path === path) @@ -50,7 +50,7 @@ export async function launchSelections( let count = 0 for (const [, items] of byProject) { - const first = items[0] + const first = items[0]! const firstCmd = buildCmd(first) const newWindowScript = [ @@ -65,7 +65,7 @@ export async function launchSelections( for (let i = 1; i < items.length; i++) { await Bun.sleep(400) - const cmd = buildCmd(items[i]) + const cmd = buildCmd(items[i]!) await runOsascript( 'tell application "System Events" to keystroke "t" using command down' diff --git a/src/input/handlers.ts b/src/input/handlers.ts index ddcb933..e058593 100644 --- a/src/input/handlers.ts +++ b/src/input/handlers.ts @@ -56,10 +56,20 @@ function toggleSetItem(set: Set, item: T) { else set.add(item) } +const MAX_TAB_NUM = 9 + function toggleRowSelection(row: DisplayRow) { const project = app.projects[row.projectIndex] if (row.type === "project" || row.type === "new-session") { - toggleSetItem(app.selectedProjects, project.path) + // Cycle tab number: none → 1 → 2 → ... → 9 → none + const current = app.selectedProjects.get(project.path) + if (current === undefined) { + app.selectedProjects.set(project.path, 1) + } else if (current < MAX_TAB_NUM) { + app.selectedProjects.set(project.path, current + 1) + } else { + app.selectedProjects.delete(project.path) + } } else if (row.type === "session") { toggleSetItem(app.selectedSessions, project.sessions![row.sessionIndex!].id) } else if (row.type === "branch") { @@ -71,6 +81,18 @@ function toggleRowSelection(row: DisplayRow) { } } +function assignTabNumber(row: DisplayRow, tabNum: number) { + const project = app.projects[row.projectIndex] + if (row.type === "project" || row.type === "new-session") { + const current = app.selectedProjects.get(project.path) + if (current === tabNum) { + app.selectedProjects.delete(project.path) // toggle off if same number + } else { + app.selectedProjects.set(project.path, tabNum) + } + } +} + function syntheticKey(name: string, shift = false, ctrl = false): KeyEvent { return { name, shift, ctrl, meta: false, preventDefault: NOOP, stopPropagation: NOOP } as KeyEvent } @@ -290,7 +312,7 @@ export async function handleKeypress(key: KeyEvent) { } case "a": - for (const p of app.projects) app.selectedProjects.add(p.path) + for (const p of app.projects) app.selectedProjects.set(p.path, 1) break case "n": @@ -339,7 +361,7 @@ export async function handleKeypress(key: KeyEvent) { case "o": { if (app.selectedProjects.size === 0 && app.selectedSessions.size === 0) { const oRow = app.displayRows[app.cursor] - if (oRow) app.selectedProjects.add(app.projects[oRow.projectIndex].path) + if (oRow) app.selectedProjects.set(app.projects[oRow.projectIndex].path, 1) } if (app.selectedProjects.size > 0 || app.selectedSessions.size > 0) { await launchSelections(app.projects, app.selectedProjects, app.selectedSessions, app.selectedBranches) @@ -350,6 +372,13 @@ export async function handleKeypress(key: KeyEvent) { break } + case "1": case "2": case "3": case "4": case "5": + case "6": case "7": case "8": case "9": { + const row = app.displayRows[app.cursor] + assignTabNumber(row, parseInt(key.name)) + break + } + case "q": case "escape": app.destroyed = true diff --git a/src/lib/state.ts b/src/lib/state.ts index be22839..65f2fb8 100644 --- a/src/lib/state.ts +++ b/src/lib/state.ts @@ -18,7 +18,7 @@ export const app = { // Data projects: [] as Project[], - selectedProjects: new Set(), + selectedProjects: new Map(), // path → tab number selectedSessions: new Set(), selectedBranches: new Map(), cursor: 0, diff --git a/src/ui/formatters.ts b/src/ui/formatters.ts index 9e8d475..8acc010 100644 --- a/src/ui/formatters.ts +++ b/src/ui/formatters.ts @@ -22,7 +22,25 @@ export function fmtSyncIndicator(ahead: number, behind: number): string { return parts.join("") } -export function fmtProjectRow(project: import("../lib/types").Project, isSelected: boolean) { +const TAB_COLORS = [ + cyan, // 1 + green, // 2 + yellow, // 3 + magenta, // 4 + (s: string) => fg("#ff9e64")(s), // 5 + (s: string) => fg("#7dcfff")(s), // 6 + (s: string) => fg("#bb9af7")(s), // 7 + (s: string) => fg("#73daca")(s), // 8 + (s: string) => fg("#b4f9f8")(s), // 9 +] + +function fmtTabCheck(tabNum: number | undefined) { + if (tabNum === undefined) return " " + const color = TAB_COLORS[(tabNum - 1) % TAB_COLORS.length]! + return color(String(tabNum)) +} + +export function fmtProjectRow(project: import("../lib/types").Project, isSelected: number | undefined) { let activeDot: string let activeTag: string if (project.activeSessions > 0) { @@ -39,7 +57,7 @@ export function fmtProjectRow(project: import("../lib/types").Project, isSelecte activeDot = dim("○") activeTag = " " } - const check = isSelected ? green("✓") : " " + const check = fmtTabCheck(isSelected) const arrow = project.expanded ? "▼" : "▶" const name = project.name.length > 28 ? project.name.slice(0, 25) + "..." : project.name @@ -80,7 +98,7 @@ export function fmtSessionRow( ) { const project = app.projects[projectIdx] const session = project.sessions![sessionIdx] - const check = isSelected ? green("✓") : " " + const check = isSelected ? green("✓") : " " // sessions still use boolean check const prefix = isLastSession ? "│ " : "├─" const title = session.title.length > 55 @@ -123,8 +141,8 @@ export function fmtSessionRow( ${dim("│")} ${dim("Claude:")} ${fg(ACCENT)('"' + responseText + '"')}` } -export function fmtNewSessionRow(projectIdx: number, isSelected: boolean) { - const check = isSelected ? green("✓") : " " +export function fmtNewSessionRow(projectIdx: number, isSelected: number | undefined) { + const check = fmtTabCheck(isSelected) return t` ${dim("└─")} [${check}] ${green("+ New session")}` } diff --git a/src/ui/panels.ts b/src/ui/panels.ts index b270f95..fd9ce28 100644 --- a/src/ui/panels.ts +++ b/src/ui/panels.ts @@ -103,17 +103,20 @@ export function updateTabBar() { export function updateHeader() { const total = app.selectedProjects.size + app.selectedSessions.size + // Count distinct tab groups + const tabGroups = new Set(app.selectedProjects.values()) + const tabNote = tabGroups.size > 1 ? ` → ${tabGroups.size} tabs` : "" const branchNote = app.selectedBranches.size > 0 ? ` (${app.selectedBranches.size} branch switch)` : "" const modeLabel = app.demoMode ? " [DEMO]" : "" const activeCount = app.projects.reduce((sum, p) => sum + (p.activeSessions > 0 ? 1 : 0), 0) const busyCount = app.projects.reduce((sum, p) => sum + (p.busySessions > 0 ? 1 : 0), 0) const idleCount = activeCount - busyCount if (activeCount > 0) { - app.headerText.content = t` ${bold("cladm")}${yellow(modeLabel)} — ${String(total)} selected${branchNote} ${dim( + app.headerText.content = t` ${bold("cladm")}${yellow(modeLabel)} — ${String(total)} selected${tabNote}${branchNote} ${dim( `sort: ${app.sortLabels[app.sortMode]} │ ${app.projects.length} projects` )} │ ${green(`${busyCount} busy`)} ${yellow(`${idleCount} idle`)}` } else { - app.headerText.content = t` ${bold("cladm")}${yellow(modeLabel)} — ${String(total)} selected${branchNote} ${dim( + app.headerText.content = t` ${bold("cladm")}${yellow(modeLabel)} — ${String(total)} selected${tabNote}${branchNote} ${dim( `sort: ${app.sortLabels[app.sortMode]} │ ${app.projects.length} projects` )}` } @@ -291,14 +294,14 @@ function renderRowContent(i: number) { let content: ReturnType let rowHeight = 1 if (row.type === "project") { - content = fmtProjectRow(project, app.selectedProjects.has(project.path)) + content = fmtProjectRow(project, app.selectedProjects.get(project.path)) } else if (row.type === "session") { content = fmtSessionRow(row.projectIndex, row.sessionIndex!, app.selectedSessions.has(project.sessions![row.sessionIndex!].id), false) rowHeight = 3 } else if (row.type === "branch") { content = fmtBranchRow(row.projectIndex, row.branchName!, app.selectedBranches.get(project.path) === row.branchName) } else { - content = fmtNewSessionRow(row.projectIndex, app.selectedProjects.has(project.path)) + content = fmtNewSessionRow(row.projectIndex, app.selectedProjects.get(project.path)) } const isCursor = i === app.cursor