cru-teams 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,110 @@
1
+ import { z } from 'incur'
2
+ import { readFileSync, readdirSync, existsSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+ import { homedir } from 'node:os'
5
+ import { listTeams } from '@/lib/teams'
6
+
7
+ function listTaskTeams(): string[] {
8
+ const dir = join(homedir(), '.claude', 'tasks')
9
+ if (!existsSync(dir)) return []
10
+ return readdirSync(dir).filter((f) => {
11
+ const full = join(dir, f)
12
+ try { return readdirSync(full).some((e) => e.endsWith('.json')) } catch { return false }
13
+ })
14
+ }
15
+
16
+ interface Task {
17
+ id: string
18
+ subject: string
19
+ description?: string
20
+ status: string
21
+ owner?: string
22
+ activeForm?: string
23
+ blocks: string[]
24
+ blockedBy: string[]
25
+ }
26
+
27
+ function tasksDir(teamName: string): string {
28
+ return join(homedir(), '.claude', 'tasks', teamName)
29
+ }
30
+
31
+ function readTasks(teamName: string): Task[] {
32
+ const dir = tasksDir(teamName)
33
+ if (!existsSync(dir)) return []
34
+
35
+ const tasks: Task[] = []
36
+ for (const file of readdirSync(dir)) {
37
+ if (!file.endsWith('.json')) continue
38
+ try {
39
+ tasks.push(JSON.parse(readFileSync(join(dir, file), 'utf-8')))
40
+ } catch {}
41
+ }
42
+ return tasks.sort((a, b) => Number(a.id) - Number(b.id))
43
+ }
44
+
45
+ const BOLD = '\x1b[1m'
46
+ const DIM = '\x1b[2m'
47
+ const RESET = '\x1b[0m'
48
+ const GREEN = '\x1b[32m'
49
+ const YELLOW = '\x1b[33m'
50
+ const RED = '\x1b[31m'
51
+ const CYAN = '\x1b[36m'
52
+
53
+ function statusIcon(status: string): string {
54
+ switch (status) {
55
+ case 'completed': return `${GREEN}✓${RESET}`
56
+ case 'in_progress': return `${YELLOW}◉${RESET}`
57
+ case 'blocked': return `${RED}✗${RESET}`
58
+ default: return `${DIM}○${RESET}`
59
+ }
60
+ }
61
+
62
+ export const tasks = {
63
+ description: 'List tasks for a team',
64
+ args: z.object({
65
+ team: z.string().optional().describe('Team name (omit to show tasks from all active teams)'),
66
+ }),
67
+ options: z.object({
68
+ all: z.boolean().default(false).describe('Include tasks from all teams (not just active)'),
69
+ status: z.enum(['pending', 'in_progress', 'completed', 'blocked']).optional().describe('Filter by status'),
70
+ }),
71
+ run(c) {
72
+ const teamNames = c.args.team ? [c.args.team] : listTaskTeams()
73
+ if (teamNames.length === 0) return { teams: [], message: 'No teams found' }
74
+
75
+ const result: Array<{ team: string; tasks: Task[] }> = []
76
+
77
+ for (const name of teamNames) {
78
+ let teamTasks = readTasks(name)
79
+ if (teamTasks.length === 0) continue
80
+ if (c.options.status) {
81
+ teamTasks = teamTasks.filter((t) => t.status === c.options.status)
82
+ }
83
+ if (teamTasks.length > 0) {
84
+ result.push({ team: name, tasks: teamTasks })
85
+ }
86
+ }
87
+
88
+ if (result.length === 0) {
89
+ return { teams: [], message: 'No tasks found' }
90
+ }
91
+
92
+ if (c.agent) {
93
+ return { teams: result }
94
+ }
95
+
96
+ // Human-readable output
97
+ for (const { team, tasks: teamTasks } of result) {
98
+ const done = teamTasks.filter((t) => t.status === 'completed').length
99
+ console.log(`\n${BOLD}${team}${RESET} ${DIM}— ${done}/${teamTasks.length} done${RESET}`)
100
+
101
+ for (const t of teamTasks) {
102
+ const icon = statusIcon(t.status)
103
+ const owner = t.owner ? ` ${CYAN}@${t.owner}${RESET}` : ''
104
+ const blocked = t.blockedBy.length > 0 ? ` ${RED}blocked by ${t.blockedBy.join(', ')}${RESET}` : ''
105
+ console.log(` ${icon} ${DIM}#${t.id}${RESET} ${t.subject}${owner}${blocked}`)
106
+ }
107
+ }
108
+ console.log()
109
+ },
110
+ }
@@ -0,0 +1,102 @@
1
+ import { z } from 'incur'
2
+ import { readTeamConfig, listTeams as getTeams } from '@/lib/teams'
3
+ import { loadPanes, isTeamAlive } from '@/lib/panes'
4
+
5
+ function age(createdAt: number): string {
6
+ const ms = Date.now() - createdAt
7
+ const mins = Math.floor(ms / 60_000)
8
+ if (mins < 60) return `${mins}m ago`
9
+ const hrs = Math.floor(mins / 60)
10
+ if (hrs < 24) return `${hrs}h ago`
11
+ const days = Math.floor(hrs / 24)
12
+ return `${days}d ago`
13
+ }
14
+
15
+ function teamDetail(teamName: string) {
16
+ const config = readTeamConfig(teamName)
17
+ const cruPanes = loadPanes(teamName)
18
+
19
+ const members = config.members.map((m) => ({
20
+ name: m.name,
21
+ role: m.agentType || 'worker',
22
+ pane: m.tmuxPaneId || 'none',
23
+ color: m.color || '',
24
+ active: m.isActive ?? true,
25
+ }))
26
+
27
+ // Merge workers from cru pane tracking that aren't in Claude's config yet
28
+ if (cruPanes) {
29
+ const known = new Set(members.map((m) => m.name))
30
+ for (const w of cruPanes.workers) {
31
+ if (!known.has(w.name)) {
32
+ members.push({
33
+ name: w.name,
34
+ role: 'worker',
35
+ pane: w.paneId,
36
+ color: w.color,
37
+ active: true,
38
+ })
39
+ }
40
+ }
41
+ for (const m of members) {
42
+ if (m.pane === 'none') {
43
+ const tracked = cruPanes.workers.find((w) => w.name === m.name)
44
+ if (tracked) {
45
+ m.pane = tracked.paneId
46
+ if (!m.color) m.color = tracked.color
47
+ }
48
+ if (m.name === 'team-lead') m.pane = cruPanes.leadPane
49
+ }
50
+ }
51
+ }
52
+
53
+ return {
54
+ team: config.name,
55
+ description: config.description,
56
+ status: isTeamAlive(teamName) ? 'active' : 'dead',
57
+ age: age(config.createdAt),
58
+ members,
59
+ }
60
+ }
61
+
62
+ export const teams = {
63
+ description: 'List teams or show team detail',
64
+ args: z.object({
65
+ team: z.string().optional().describe('Team name (omit to list all teams)'),
66
+ }),
67
+ options: z.object({
68
+ all: z.boolean().default(false).describe('Include dead teams'),
69
+ }),
70
+ run(c) {
71
+ // Show detail for a specific team
72
+ if (c.args.team) return teamDetail(c.args.team)
73
+
74
+ // List all teams
75
+ const teamNames = getTeams()
76
+ if (teamNames.length === 0) return { teams: [], message: 'No teams' }
77
+
78
+ const result = teamNames.map((t) => {
79
+ const config = readTeamConfig(t)
80
+ const alive = isTeamAlive(t)
81
+ return {
82
+ name: config.name,
83
+ members: config.members.length,
84
+ status: alive ? 'active' : 'dead',
85
+ age: age(config.createdAt),
86
+ description: config.description,
87
+ }
88
+ })
89
+
90
+ const filtered = c.options.all ? result : result.filter((t) => t.status === 'active')
91
+
92
+ if (filtered.length === 0 && !c.options.all) {
93
+ const dead = result.length
94
+ if (!c.agent && dead > 0) {
95
+ console.log(`No active teams (${dead} dead — use --all to show, or 'cru clean' to remove)`)
96
+ }
97
+ return { teams: [], message: 'No active teams' }
98
+ }
99
+
100
+ return { teams: filtered }
101
+ },
102
+ }
@@ -0,0 +1,61 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { homedir } from 'node:os'
4
+ import type { LayoutConf } from './tmux'
5
+
6
+ export const CONFIG_NAME = '.cru.json'
7
+
8
+ export interface CruConfig {
9
+ layout: LayoutConf
10
+ }
11
+
12
+ export const DEFAULTS: CruConfig = {
13
+ layout: {
14
+ lead: { position: 'left', size: 40 },
15
+ grid: { fill: 'row', maxCols: null, maxRows: null },
16
+ },
17
+ }
18
+
19
+ export function configPaths(): string[] {
20
+ return [
21
+ join(process.cwd(), CONFIG_NAME),
22
+ join(homedir(), '.config', 'cru', 'config.json'),
23
+ ]
24
+ }
25
+
26
+ export function loadConfig(): CruConfig {
27
+ for (const p of configPaths()) {
28
+ if (existsSync(p)) {
29
+ try {
30
+ const raw = JSON.parse(readFileSync(p, 'utf-8'))
31
+ return deepMerge(structuredClone(DEFAULTS), raw)
32
+ } catch {
33
+ // malformed — fall through
34
+ }
35
+ }
36
+ }
37
+ return structuredClone(DEFAULTS)
38
+ }
39
+
40
+ export function writeConfig(path: string, config: CruConfig): void {
41
+ mkdirSync(join(path, '..'), { recursive: true })
42
+ writeFileSync(path, JSON.stringify(config, null, 2) + '\n')
43
+ }
44
+
45
+ export function deepMerge<T extends Record<string, any>>(target: T, source: Record<string, any>): T {
46
+ const t = target as Record<string, any>
47
+ for (const key of Object.keys(source)) {
48
+ if (
49
+ source[key] &&
50
+ typeof source[key] === 'object' &&
51
+ !Array.isArray(source[key]) &&
52
+ t[key] &&
53
+ typeof t[key] === 'object'
54
+ ) {
55
+ deepMerge(t[key], source[key])
56
+ } else {
57
+ t[key] = source[key]
58
+ }
59
+ }
60
+ return target
61
+ }
package/src/lib/env.ts ADDED
@@ -0,0 +1,45 @@
1
+ import { execSync } from 'node:child_process'
2
+
3
+ /** Check if a binary exists on PATH. Returns its path or null. */
4
+ export function hasBinary(name: string): string | null {
5
+ try {
6
+ return execSync(`which ${name}`, { encoding: 'utf-8' }).trim()
7
+ } catch {
8
+ return null
9
+ }
10
+ }
11
+
12
+ /** Get version string from a command, or null on failure. */
13
+ export function getVersion(cmd: string): string | null {
14
+ try {
15
+ return execSync(cmd, { encoding: 'utf-8' }).trim()
16
+ } catch {
17
+ return null
18
+ }
19
+ }
20
+
21
+ /** Check if we're inside an active tmux session. */
22
+ export function inTmux(): boolean {
23
+ return !!process.env.TMUX
24
+ }
25
+
26
+ /** Detect terminal emulator from environment. */
27
+ export function detectTerminal(): string {
28
+ // Check TERM_PROGRAM first — it reflects the actual terminal in use,
29
+ // while vars like ITERM_SESSION_ID can leak from parent processes.
30
+ const tp = process.env.TERM_PROGRAM
31
+ if (tp === 'vscode') return 'vscode'
32
+ if (tp === 'iTerm.app') return 'iterm2'
33
+ if (tp === 'Apple_Terminal') return 'terminal'
34
+ if (tp === 'WezTerm') return 'wezterm'
35
+ if (tp === 'ghostty') return 'ghostty'
36
+ if (tp === 'Alacritty') return 'alacritty'
37
+ if (process.env.ITERM_SESSION_ID) return 'iterm2'
38
+ if (process.env.WT_SESSION) return 'windows-terminal'
39
+ return tp || 'unknown'
40
+ }
41
+
42
+ /** Check if we're in Ghostty without tmux (native pane mode). */
43
+ export function inGhostty(): boolean {
44
+ return detectTerminal() === 'ghostty' && !inTmux()
45
+ }
@@ -0,0 +1,153 @@
1
+ import { execFileSync, execSync } from 'node:child_process'
2
+
3
+ /** Run an AppleScript snippet targeting Ghostty. */
4
+ export function ghostty(script: string): string {
5
+ const wrapped = `tell application "Ghostty"\n${script}\nend tell`
6
+ return execSync('osascript', { input: wrapped, encoding: 'utf-8' }).trim()
7
+ }
8
+
9
+ /**
10
+ * Find the Ghostty terminal ID that THIS process is running in.
11
+ *
12
+ * Ghostty's AppleScript API doesn't expose PID or TTY per terminal,
13
+ * so we correlate via creation order:
14
+ * 1. Walk up the process tree to find our ancestor TTY
15
+ * 2. Find all of Ghostty's direct children (login processes) + their TTYs
16
+ * 3. Get all terminal IDs from AppleScript
17
+ * 4. Both lists are in creation order — match by position
18
+ *
19
+ * Falls back to the focused terminal if the lookup fails.
20
+ */
21
+ export function currentTerminal(): string {
22
+ try {
23
+ // 1. Walk up from our PID to find the ancestor TTY
24
+ let pid = process.pid
25
+ let ourTty: string | null = null
26
+ for (let i = 0; i < 20; i++) {
27
+ const info = execFileSync('ps', ['-o', 'pid=,ppid=,tty=', '-p', String(pid)], { encoding: 'utf-8' }).trim()
28
+ const parts = info.split(/\s+/)
29
+ const tty = parts[2]
30
+ if (tty && tty !== '??' && !tty.startsWith('??')) {
31
+ ourTty = tty
32
+ break
33
+ }
34
+ pid = parseInt(parts[1])
35
+ if (!pid || pid <= 1) break
36
+ }
37
+ if (!ourTty) throw new Error('Could not find TTY')
38
+
39
+ // 2. Find Ghostty's PID
40
+ const ghosttyPid = execFileSync(
41
+ 'ps', ['-ax', '-o', 'pid=,comm='],
42
+ { encoding: 'utf-8' },
43
+ ).split('\n')
44
+ .find((l) => l.includes('Ghostty.app/Contents/MacOS/ghostty'))
45
+ ?.trim().split(/\s+/)[0]
46
+ if (!ghosttyPid) throw new Error('Ghostty process not found')
47
+
48
+ // 3. Find all Ghostty children (login processes) sorted by PID (= creation order)
49
+ const children = execFileSync(
50
+ 'ps', ['-ax', '-o', 'pid=,ppid=,tty='],
51
+ { encoding: 'utf-8' },
52
+ ).split('\n')
53
+ .map((l) => l.trim().split(/\s+/))
54
+ .filter((p) => p[1] === ghosttyPid)
55
+ .sort((a, b) => parseInt(a[0]) - parseInt(b[0]))
56
+
57
+ // 4. Find our position in the children list
58
+ const ourIndex = children.findIndex((c) => c[2] === ourTty)
59
+ if (ourIndex < 0) throw new Error(`TTY ${ourTty} not found in Ghostty children`)
60
+
61
+ // 5. Get terminal IDs from AppleScript (same creation order)
62
+ const terminalIds = listAllTerminals()
63
+ if (ourIndex >= terminalIds.length) throw new Error('Terminal index out of range')
64
+
65
+ return terminalIds[ourIndex]
66
+ } catch {
67
+ // Fallback: use the focused terminal (original behavior)
68
+ return ghostty('get id of focused terminal of selected tab of front window')
69
+ }
70
+ }
71
+
72
+ /** Get the frontmost window ID. */
73
+ export function currentWindow(): string {
74
+ return ghostty('get id of front window')
75
+ }
76
+
77
+ /** List all terminals in the front window with their IDs. */
78
+ export function listTerminals(_windowId?: string): Array<{ id: string; index: number }> {
79
+ // Ghostty's window id format doesn't work with `window id` in AppleScript.
80
+ // Use front window — caller should ensure the right window is focused.
81
+ const ids = ghostty('get id of every terminal of front window')
82
+ if (!ids) return []
83
+ return ids.split(', ').map((id, i) => ({ id, index: i }))
84
+ }
85
+
86
+ /** List all terminal IDs across all windows. */
87
+ export function listAllTerminals(): string[] {
88
+ const ids = ghostty('get id of every terminal')
89
+ if (!ids) return []
90
+ return ids.split(', ')
91
+ }
92
+
93
+ /** Split a terminal and return the new terminal's ID. */
94
+ export function splitTerminal(terminalId: string, direction: 'right' | 'down'): string {
95
+ // Snapshot terminal IDs before split
96
+ const before = new Set(listAllTerminals())
97
+ ghostty(`split terminal id "${terminalId}" direction ${direction}`)
98
+ // Find the new terminal by diffing
99
+ const after = listAllTerminals()
100
+ const newId = after.find((id) => !before.has(id))
101
+ if (!newId) throw new Error('Split did not create a new terminal')
102
+ return newId
103
+ }
104
+
105
+ /** Close a terminal. */
106
+ export function closeTerminal(terminalId: string): void {
107
+ try {
108
+ ghostty(`close terminal id "${terminalId}"`)
109
+ } catch {
110
+ // terminal may already be closed
111
+ }
112
+ }
113
+
114
+ /** Send paste-style text input to a terminal. */
115
+ export function inputText(terminalId: string, text: string): void {
116
+ const escaped = text.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
117
+ ghostty(`input text "${escaped}" to terminal id "${terminalId}"`)
118
+ }
119
+
120
+ /** Send a key press to a terminal. */
121
+ export function sendKey(terminalId: string, key: string): void {
122
+ ghostty(`send key "${key}" to terminal id "${terminalId}"`)
123
+ }
124
+
125
+ /** Send text followed by Enter (like typing a command). */
126
+ export function sendCommand(terminalId: string, text: string): void {
127
+ inputText(terminalId, text)
128
+ sendKey(terminalId, 'enter')
129
+ }
130
+
131
+ /** Focus a terminal and bring its window to front. */
132
+ export function focusTerminal(terminalId: string): void {
133
+ ghostty(`focus terminal id "${terminalId}"`)
134
+ }
135
+
136
+ /** Get Ghostty version string. */
137
+ export function ghosttyVersion(): string | null {
138
+ try {
139
+ return ghostty('get version')
140
+ } catch {
141
+ return null
142
+ }
143
+ }
144
+
145
+ /** Check if Ghostty is running and scriptable. */
146
+ export function isGhosttyScriptable(): boolean {
147
+ try {
148
+ ghostty('get name')
149
+ return true
150
+ } catch {
151
+ return false
152
+ }
153
+ }
@@ -0,0 +1,162 @@
1
+ import type { LayoutConf } from './tmux'
2
+
3
+ export function computeGrid(N: number, conf: LayoutConf): { cols: number; rows: number } {
4
+ let cols = Math.ceil(Math.sqrt(N))
5
+ let rows = Math.ceil(N / cols)
6
+
7
+ if (conf.grid.maxCols && cols > conf.grid.maxCols) {
8
+ cols = conf.grid.maxCols
9
+ rows = Math.ceil(N / cols)
10
+ }
11
+ if (conf.grid.maxRows && rows > conf.grid.maxRows) {
12
+ rows = conf.grid.maxRows
13
+ cols = Math.ceil(N / rows)
14
+ }
15
+ return { cols, rows }
16
+ }
17
+
18
+ export function buildLayout(
19
+ W: number, H: number, leadId: number | string, workerIds: (number | string)[], conf: LayoutConf,
20
+ ): string {
21
+ const pos = conf.lead.position
22
+ const sizePct = conf.lead.size
23
+ const N = workerIds.length
24
+ const { cols, rows } = computeGrid(N, conf)
25
+ const isHorizontal = pos === 'left' || pos === 'right'
26
+
27
+ const leadSize = Math.floor(((isHorizontal ? W : H) * sizePct) / 100)
28
+ const gridTotal = (isHorizontal ? W : H) - leadSize - 1
29
+ const gridCross = isHorizontal ? H : W
30
+
31
+ const colSizes = distribute(isHorizontal ? gridTotal : gridCross, cols)
32
+ const rowSizes = distribute(isHorizontal ? gridCross : gridTotal, rows)
33
+
34
+ const ordered =
35
+ conf.grid.fill === 'column'
36
+ ? reorderColumnFirst(workerIds, rows, cols)
37
+ : workerIds
38
+
39
+ const leadFirst = pos === 'left' || pos === 'top'
40
+ const gridOrigin = leadFirst ? leadSize + 1 : 0
41
+ const leadOrigin = leadFirst ? 0 : gridTotal + 1
42
+
43
+ const rowLayouts = buildRows({
44
+ rows,
45
+ cols,
46
+ N,
47
+ rowSizes,
48
+ colSizes,
49
+ gridOrigin,
50
+ gridTotal,
51
+ ordered,
52
+ isHorizontal,
53
+ })
54
+
55
+ let gridLayout: string
56
+ if (rowLayouts.length === 1) {
57
+ gridLayout = rowLayouts[0]
58
+ } else {
59
+ const gx = isHorizontal ? gridOrigin : 0
60
+ const gy = isHorizontal ? 0 : gridOrigin
61
+ const gw = isHorizontal ? gridTotal : W
62
+ const gh = isHorizontal ? H : gridTotal
63
+ const bracket = isHorizontal
64
+ ? `[${rowLayouts.join(',')}]`
65
+ : `{${rowLayouts.join(',')}}`
66
+ gridLayout = `${gw}x${gh},${gx},${gy}${bracket}`
67
+ }
68
+
69
+ const lx = isHorizontal ? leadOrigin : leadFirst ? 0 : gridTotal + 1
70
+ const ly = isHorizontal ? 0 : leadFirst ? 0 : gridTotal + 1
71
+ const lw = isHorizontal ? leadSize : W
72
+ const lh = isHorizontal ? H : leadSize
73
+ const leadLayout = `${lw}x${lh},${lx},${ly},${leadId}`
74
+
75
+ const parts = leadFirst
76
+ ? [leadLayout, gridLayout]
77
+ : [gridLayout, leadLayout]
78
+ const outerBracket = isHorizontal
79
+ ? `{${parts.join(',')}}`
80
+ : `[${parts.join(',')}]`
81
+
82
+ return `${W}x${H},0,0${outerBracket}`
83
+ }
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // Internal helpers
87
+ // ---------------------------------------------------------------------------
88
+
89
+ function distribute(total: number, count: number): number[] {
90
+ const base = Math.floor((total - (count - 1)) / count)
91
+ const sizes = Array(count).fill(base)
92
+ sizes[count - 1] = total - (count - 1) - base * (count - 1)
93
+ return sizes
94
+ }
95
+
96
+ function reorderColumnFirst<T>(ids: T[], rows: number, cols: number): T[] {
97
+ const out: T[] = []
98
+ for (let r = 0; r < rows; r++) {
99
+ for (let c = 0; c < cols; c++) {
100
+ const idx = c * rows + r
101
+ if (idx < ids.length) out.push(ids[idx])
102
+ }
103
+ }
104
+ return out
105
+ }
106
+
107
+ interface BuildRowsOpts {
108
+ rows: number
109
+ cols: number
110
+ N: number
111
+ rowSizes: number[]
112
+ colSizes: number[]
113
+ gridOrigin: number
114
+ gridTotal: number
115
+ ordered: (number | string)[]
116
+ isHorizontal: boolean
117
+ }
118
+
119
+ function buildRows(opts: BuildRowsOpts): string[] {
120
+ const {
121
+ rows, cols, N, rowSizes, colSizes,
122
+ gridOrigin, gridTotal, ordered, isHorizontal,
123
+ } = opts
124
+ const rowLayouts: string[] = []
125
+ let idx = 0
126
+ let primaryOffset = 0
127
+
128
+ for (let r = 0; r < rows; r++) {
129
+ const rSize = rowSizes[r]
130
+ const cellParts: string[] = []
131
+ let secondaryOffset = gridOrigin
132
+
133
+ for (let c = 0; c < cols; c++) {
134
+ if (idx >= N) break
135
+ let cSize = colSizes[c]
136
+ // Last pane in an incomplete row — expand to fill remaining space
137
+ if (idx === N - 1 && c < cols - 1) {
138
+ cSize = gridTotal - (secondaryOffset - gridOrigin)
139
+ }
140
+ const x = isHorizontal ? secondaryOffset : primaryOffset
141
+ const y = isHorizontal ? primaryOffset : secondaryOffset
142
+ const w = isHorizontal ? cSize : rSize
143
+ const h = isHorizontal ? rSize : cSize
144
+ cellParts.push(`${w}x${h},${x},${y},${ordered[idx]}`)
145
+ secondaryOffset += cSize + 1
146
+ idx++
147
+ }
148
+
149
+ if (cellParts.length === 1) {
150
+ rowLayouts.push(cellParts[0])
151
+ } else {
152
+ const x = isHorizontal ? gridOrigin : primaryOffset
153
+ const y = isHorizontal ? primaryOffset : gridOrigin
154
+ const w = isHorizontal ? gridTotal : rSize
155
+ const h = isHorizontal ? rSize : gridTotal
156
+ rowLayouts.push(`${w}x${h},${x},${y}{${cellParts.join(',')}}`)
157
+ }
158
+ primaryOffset += rSize + 1
159
+ }
160
+
161
+ return rowLayouts
162
+ }