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.
- package/README.md +84 -0
- package/package.json +33 -0
- package/skills/cru/SKILL.md +54 -0
- package/src/cli.ts +49 -0
- package/src/commands/clean.ts +78 -0
- package/src/commands/config.ts +11 -0
- package/src/commands/doctor.ts +82 -0
- package/src/commands/init.ts +35 -0
- package/src/commands/logs.ts +302 -0
- package/src/commands/panes.ts +376 -0
- package/src/commands/tasks.ts +110 -0
- package/src/commands/teams.ts +102 -0
- package/src/lib/config.ts +61 -0
- package/src/lib/env.ts +45 -0
- package/src/lib/ghostty.ts +153 -0
- package/src/lib/layout.ts +162 -0
- package/src/lib/mirror.ts +149 -0
- package/src/lib/panes.ts +62 -0
- package/src/lib/paths.ts +6 -0
- package/src/lib/preflight.ts +94 -0
- package/src/lib/teams.ts +57 -0
- package/src/lib/tmux.ts +167 -0
|
@@ -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
|
+
}
|