feat: numbered tab selection for launching projects into separate grid tabs
Space cycles tab number (1→2→...→9→off), digit keys 1-9 assign directly. Each number gets a unique color in the picker brackets. On launch, projects are grouped by tab number into separate grid tabs. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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<number, LaunchItem[]>()
|
||||
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()
|
||||
|
||||
@@ -9,13 +9,13 @@ interface LaunchItem {
|
||||
|
||||
export async function launchSelections(
|
||||
projects: Project[],
|
||||
selectedProjects: Set<string>,
|
||||
selectedProjects: Map<string, number>,
|
||||
selectedSessions: Set<string>,
|
||||
selectedBranches: Map<string, string> = new Map()
|
||||
): Promise<number> {
|
||||
const byProject = new Map<string, LaunchItem[]>()
|
||||
|
||||
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'
|
||||
|
||||
@@ -56,10 +56,20 @@ function toggleSetItem<T>(set: Set<T>, 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
|
||||
|
||||
@@ -18,7 +18,7 @@ export const app = {
|
||||
|
||||
// Data
|
||||
projects: [] as Project[],
|
||||
selectedProjects: new Set<string>(),
|
||||
selectedProjects: new Map<string, number>(), // path → tab number
|
||||
selectedSessions: new Set<string>(),
|
||||
selectedBranches: new Map<string, string>(),
|
||||
cursor: 0,
|
||||
|
||||
@@ -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")}`
|
||||
}
|
||||
|
||||
|
||||
@@ -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<typeof t>
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user