@ohzw/worktree-command-tui 0.1.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 ADDED
@@ -0,0 +1,88 @@
1
+ # worktree-command-tui
2
+
3
+ `worktree-command-tui` is a terminal UI (TUI) tool for operating multiple Git worktrees from one repository.
4
+ It helps you inspect, start, and stop per-worktree processes with quick keyboard-driven workflows.
5
+
6
+ ## Features
7
+
8
+ - List and monitor worktrees for the current repository
9
+ - Start/stop worktree-specific commands
10
+ - Keep process handling centralized for each session
11
+ - Persist namespace-aware runtime state
12
+ - Support JSONC config (with comments and trailing commas)
13
+
14
+ ## Requirements
15
+
16
+ - Node.js `>=20`
17
+ - A Git repository in the target working directory
18
+
19
+ ## Installation
20
+
21
+ ```bash
22
+ npm install -g @ohzw/worktree-command-tui
23
+ ```
24
+
25
+ ## Usage
26
+
27
+ ### 1) Initialize configuration
28
+
29
+ Run this once in a repository root (or any subdirectory of the repository):
30
+
31
+ ```bash
32
+ wctui init
33
+ ```
34
+
35
+ This creates `.worktree-command-tui.jsonc` with a sensible default configuration.
36
+
37
+ To regenerate an existing configuration:
38
+
39
+ ```bash
40
+ wctui init --force
41
+ ```
42
+
43
+ ### 2) Start the TUI
44
+
45
+ ```bash
46
+ wctui
47
+ ```
48
+
49
+ `worktree-command-tui` is still available as a compatibility alias.
50
+
51
+ If no configuration file is found, the CLI will prompt you to run `wctui init`.
52
+
53
+ ## Configuration
54
+
55
+ The tool reads these files in this order:
56
+
57
+ 1. `.worktree-command-tui.jsonc`
58
+ 2. `.worktree-command-tui.json`
59
+
60
+ A minimal example of the generated config:
61
+
62
+ ```jsonc
63
+ {
64
+ // Session namespace used for logs/state
65
+ "namespace": "worktree-command-tui",
66
+ // Command executed in each selected worktree
67
+ "command": ["npm", "run", "start"],
68
+ // Port used for cleanup/monitoring
69
+ "port": 3000,
70
+ // Required files that must exist in a worktree
71
+ "requiredFiles": ["package.json"],
72
+ // Optional command substrings considered orphaned processes
73
+ "orphanMatchers": []
74
+ }
75
+ ```
76
+
77
+ ## Development
78
+
79
+ ```bash
80
+ npm install
81
+ npm run test # Run test suite
82
+ npm run typecheck # Run TypeScript type-check
83
+ npm run build # Build distributable output to dist/
84
+ ```
85
+
86
+ ## License
87
+
88
+ MIT
package/dist/app.d.ts ADDED
@@ -0,0 +1,21 @@
1
+ import type { AppActions, AppModel } from './core/runtime.js';
2
+ export interface ShellDimensions {
3
+ rootWidth: number;
4
+ rootHeight: number;
5
+ bodyWidth: number;
6
+ listWidth: number;
7
+ actionWidth: number;
8
+ }
9
+ export interface AppWindowSize {
10
+ columns: number;
11
+ rows: number;
12
+ }
13
+ export declare function getShellDimensions(columns: number, rows: number): ShellDimensions;
14
+ export declare function shouldUseCompactLayout(columns: number, rows: number, worktreeCount?: number): boolean;
15
+ export declare function shouldUseMinimalLayout(columns: number, rows: number): boolean;
16
+ export declare function shouldStackPanes(columns: number, rows: number, worktreeCount?: number): boolean;
17
+ export declare function App({ initialModel, actions, windowSizeOverride, }: {
18
+ initialModel: AppModel;
19
+ actions: AppActions;
20
+ windowSizeOverride?: AppWindowSize;
21
+ }): import("react").JSX.Element;
package/dist/app.js ADDED
@@ -0,0 +1,139 @@
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ import { useEffect, useMemo, useRef, useState } from 'react';
3
+ import { Box, Text, useApp, useInput, useWindowSize } from 'ink';
4
+ import { ActionPanel } from './components/ActionPanel.js';
5
+ import { ContextBar } from './components/ContextBar.js';
6
+ import { Header } from './components/Header.js';
7
+ import { WorktreeList } from './components/WorktreeList.js';
8
+ function getNextSelectedPath(rows, currentPath) {
9
+ if (rows.length === 0) {
10
+ return null;
11
+ }
12
+ if (currentPath && rows.some(row => row.path === currentPath)) {
13
+ return currentPath;
14
+ }
15
+ return rows[0].path;
16
+ }
17
+ export function getShellDimensions(columns, rows) {
18
+ const rootWidth = Math.max(columns, 1);
19
+ const rootHeight = Math.max(rows, 1);
20
+ const bodyWidth = Math.max(rootWidth - 4, 1);
21
+ const maxListWidth = Math.max(1, bodyWidth - 21);
22
+ const desiredListWidth = Math.max(1, Math.floor((bodyWidth - 1) * 0.34));
23
+ const listWidth = Math.min(42, desiredListWidth, maxListWidth);
24
+ const actionWidth = Math.max(1, bodyWidth - listWidth - 1);
25
+ return { rootWidth, rootHeight, bodyWidth, listWidth, actionWidth };
26
+ }
27
+ export function shouldUseCompactLayout(columns, rows, worktreeCount = 0) {
28
+ const contentAwareRowFloor = Math.max(20, worktreeCount + 12);
29
+ return columns < 72 || rows <= contentAwareRowFloor || (columns < 96 && rows < 24);
30
+ }
31
+ export function shouldUseMinimalLayout(columns, rows) {
32
+ return columns < 20 || rows < 6;
33
+ }
34
+ export function shouldStackPanes(columns, rows, worktreeCount = 0) {
35
+ const minimumRows = Math.max(26, worktreeCount + 18);
36
+ return columns < 96 && rows >= minimumRows;
37
+ }
38
+ export function App({ initialModel, actions, windowSizeOverride, }) {
39
+ const { exit } = useApp();
40
+ const liveWindowSize = useWindowSize();
41
+ const { columns, rows } = windowSizeOverride ?? liveWindowSize;
42
+ const [model, setModel] = useState(initialModel);
43
+ const [selectedPath, setSelectedPath] = useState(initialModel.rows[0]?.path ?? null);
44
+ const inFlightRef = useRef(false);
45
+ useEffect(() => {
46
+ setSelectedPath(currentPath => getNextSelectedPath(model.rows, currentPath));
47
+ }, [model.rows]);
48
+ const selectedIndex = useMemo(() => {
49
+ if (selectedPath === null) {
50
+ return 0;
51
+ }
52
+ const foundIndex = model.rows.findIndex(row => row.path === selectedPath);
53
+ return foundIndex >= 0 ? foundIndex : 0;
54
+ }, [model.rows, selectedPath]);
55
+ const selected = model.rows[selectedIndex];
56
+ const { rootWidth, rootHeight, bodyWidth, listWidth, actionWidth } = getShellDimensions(columns, rows);
57
+ const minimalLayout = shouldUseMinimalLayout(rootWidth, rootHeight);
58
+ const compactLayout = !minimalLayout && shouldUseCompactLayout(rootWidth, rootHeight, model.rows.length);
59
+ const stackedLayout = !minimalLayout && !compactLayout && shouldStackPanes(rootWidth, rootHeight, model.rows.length);
60
+ const compactDetailPane = !stackedLayout && rootHeight <= 26;
61
+ function moveSelection(nextIndex) {
62
+ if (model.rows.length === 0) {
63
+ return;
64
+ }
65
+ setSelectedPath(model.rows[Math.min(Math.max(nextIndex, 0), model.rows.length - 1)].path);
66
+ }
67
+ async function apply(action) {
68
+ inFlightRef.current = true;
69
+ try {
70
+ const next = await action();
71
+ setModel(next);
72
+ }
73
+ catch (error) {
74
+ setModel(current => ({
75
+ ...current,
76
+ status: {
77
+ kind: 'error',
78
+ message: error instanceof Error ? error.message : String(error),
79
+ },
80
+ }));
81
+ }
82
+ finally {
83
+ inFlightRef.current = false;
84
+ }
85
+ }
86
+ useInput((input, key) => {
87
+ if (key.escape || input === 'q') {
88
+ exit();
89
+ return;
90
+ }
91
+ if (key.upArrow || input === 'k') {
92
+ moveSelection(selectedIndex - 1);
93
+ return;
94
+ }
95
+ if (key.downArrow || input === 'j') {
96
+ moveSelection(selectedIndex + 1);
97
+ return;
98
+ }
99
+ if (input === 'g') {
100
+ moveSelection(0);
101
+ return;
102
+ }
103
+ if (input === 'G') {
104
+ moveSelection(model.rows.length - 1);
105
+ return;
106
+ }
107
+ if (inFlightRef.current) {
108
+ return;
109
+ }
110
+ if (key.return && selected) {
111
+ if (selected.invalidReason) {
112
+ setModel(current => ({ ...current, status: { kind: 'error', message: selected.invalidReason } }));
113
+ return;
114
+ }
115
+ if (selected.path === model.activePath) {
116
+ setModel(current => ({ ...current, status: { kind: 'idle', message: 'already active' } }));
117
+ return;
118
+ }
119
+ setModel(current => ({ ...current, status: { kind: 'starting', message: `Starting ${selected.branch}...` } }));
120
+ void apply(() => actions.start(selected.path));
121
+ return;
122
+ }
123
+ if (input === 's') {
124
+ setModel(current => ({ ...current, status: { kind: 'stopping', message: 'Stopping active session...' } }));
125
+ void apply(() => actions.stop());
126
+ return;
127
+ }
128
+ if (input === 'r') {
129
+ void apply(() => actions.refresh());
130
+ }
131
+ });
132
+ if (minimalLayout) {
133
+ return (_jsxs(Box, { width: rootWidth, flexDirection: "column", children: [_jsxs(Text, { bold: true, color: "green", wrap: "truncate-end", children: ["A:", model.activeBranch ?? '-'] }), rootHeight >= 2 ? _jsxs(Text, { wrap: "truncate-end", children: ["S:", selected?.branch ?? '-'] }) : null, rootHeight >= 3 ? _jsxs(Text, { wrap: "truncate-end", children: ["T:", model.status.kind] }) : null, rootHeight >= 4 ? _jsx(Text, { dimColor: true, wrap: "truncate-end", children: "\u2191\u2193jk\u21B5q" }) : null] }));
134
+ }
135
+ if (compactLayout) {
136
+ return (_jsxs(Box, { width: rootWidth, borderStyle: "round", borderColor: "yellow", flexDirection: "column", paddingX: 1, children: [_jsxs(Text, { bold: true, color: "green", wrap: "truncate-end", children: ["Active: ", model.activeBranch ?? '-'] }), _jsxs(Text, { wrap: "truncate-end", children: ["Selected: ", selected?.branch ?? '-'] }), _jsxs(Text, { wrap: "truncate-end", children: ["Status: ", model.status.kind, " \u2014 ", model.status.message] }), _jsx(Text, { dimColor: true, wrap: "truncate-end", children: "Keys: \u2191\u2193/jk g/G \u21B5 s r q \u00B7 Resize terminal for split view" })] }));
137
+ }
138
+ return (_jsxs(Box, { width: rootWidth, height: stackedLayout ? undefined : rootHeight, borderStyle: "round", borderColor: "gray", flexDirection: "column", paddingX: 1, children: [_jsx(Header, { repoName: model.repoName, namespace: model.namespace, activeBranch: model.activeBranch }), _jsxs(Box, { flexDirection: stackedLayout ? 'column' : 'row', flexGrow: stackedLayout ? 0 : 1, flexShrink: 1, children: [_jsx(WorktreeList, { rows: model.rows, selectedIndex: selectedIndex, width: stackedLayout ? bodyWidth : listWidth, stacked: stackedLayout }), _jsx(ActionPanel, { selectedRow: selected, activePath: model.activePath, stacked: stackedLayout, width: stackedLayout ? bodyWidth : actionWidth, compactDetails: compactDetailPane })] }), _jsx(ContextBar, { status: model.status })] }));
139
+ }
@@ -0,0 +1,10 @@
1
+ import type { AppRow } from '../core/runtime.js';
2
+ export declare function getPullRequestColor(selectedRow: AppRow): 'green' | 'yellow' | 'red' | undefined;
3
+ export declare function getActionColor(selectedRow: AppRow): 'yellow' | 'red' | undefined;
4
+ export declare function ActionPanel({ selectedRow, activePath, stacked, width, compactDetails, }: {
5
+ selectedRow: AppRow | undefined;
6
+ activePath: string | null;
7
+ stacked: boolean;
8
+ width?: number;
9
+ compactDetails?: boolean;
10
+ }): import("react").JSX.Element;
@@ -0,0 +1,129 @@
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ function formatTags(tags) {
4
+ return tags.length === 0 ? '-' : tags.join(' · ');
5
+ }
6
+ function sanitizeInlineText(value) {
7
+ return value
8
+ .replace(/[\r\n\t\u2028\u2029]+/g, ' ')
9
+ .replace(/[\u0000-\u001f\u007f-\u009f]/g, '')
10
+ .replace(/\p{Cf}/gu, '')
11
+ .replace(/\s+/g, ' ')
12
+ .trim();
13
+ }
14
+ function formatUpstream(selectedRow) {
15
+ if (selectedRow.upstreamUnavailable) {
16
+ return 'unavailable';
17
+ }
18
+ if (!selectedRow.upstream) {
19
+ return '-';
20
+ }
21
+ return `${sanitizeInlineText(selectedRow.upstream.branch)} (↑${selectedRow.upstream.ahead} ↓${selectedRow.upstream.behind})`;
22
+ }
23
+ function formatWorkingTree(selectedRow) {
24
+ if (!selectedRow.workingTree) {
25
+ return 'unavailable';
26
+ }
27
+ const { staged, unstaged, untracked, conflicts } = selectedRow.workingTree;
28
+ if (staged === 0 && unstaged === 0 && untracked === 0 && conflicts === 0) {
29
+ return 'clean';
30
+ }
31
+ const parts = [];
32
+ if (staged > 0) {
33
+ parts.push(`index ${staged}`);
34
+ }
35
+ if (unstaged > 0) {
36
+ parts.push(`worktree ${unstaged}`);
37
+ }
38
+ if (untracked > 0) {
39
+ parts.push(`untracked ${untracked}`);
40
+ }
41
+ if (conflicts > 0) {
42
+ parts.push(`conflicts ${conflicts}`);
43
+ }
44
+ return `dirty (${parts.join(' · ')})`;
45
+ }
46
+ function formatPullRequest(selectedRow) {
47
+ if (!selectedRow.pullRequest || selectedRow.pullRequest.kind === 'none') {
48
+ return 'none';
49
+ }
50
+ if (selectedRow.pullRequest.kind === 'unavailable') {
51
+ return 'unavailable';
52
+ }
53
+ const draft = selectedRow.pullRequest.isDraft ? 'draft/' : '';
54
+ return `#${selectedRow.pullRequest.number} ${draft}${selectedRow.pullRequest.state.toLowerCase()} → ${sanitizeInlineText(selectedRow.pullRequest.baseBranch)}`;
55
+ }
56
+ function getPullRequestLabel(selectedRow) {
57
+ if (selectedRow.pullRequest?.kind === 'found' && selectedRow.pullRequest.state !== 'OPEN') {
58
+ return 'Last PR';
59
+ }
60
+ return 'PR';
61
+ }
62
+ function getPullRequestTitleLabel(selectedRow) {
63
+ if (selectedRow.pullRequest?.kind === 'found' && selectedRow.pullRequest.state !== 'OPEN') {
64
+ return 'Last PR Title';
65
+ }
66
+ return 'PR Title';
67
+ }
68
+ export function getPullRequestColor(selectedRow) {
69
+ if (!selectedRow.pullRequest || selectedRow.pullRequest.kind === 'none') {
70
+ return undefined;
71
+ }
72
+ if (selectedRow.pullRequest.kind === 'unavailable') {
73
+ return 'red';
74
+ }
75
+ if (selectedRow.pullRequest.state !== 'OPEN') {
76
+ return undefined;
77
+ }
78
+ return selectedRow.pullRequest.isDraft ? 'yellow' : 'green';
79
+ }
80
+ function getActionMessage(selectedRow, activePath) {
81
+ if (selectedRow.invalidReason) {
82
+ return 'Cannot start this worktree.';
83
+ }
84
+ if (selectedRow.path === activePath) {
85
+ return 'Already active. Press s to stop the current session.';
86
+ }
87
+ return 'Press Enter to start here and switch the active session.';
88
+ }
89
+ export function getActionColor(selectedRow) {
90
+ if (selectedRow.invalidReason) {
91
+ return 'red';
92
+ }
93
+ if ((selectedRow.workingTree?.conflicts ?? 0) > 0) {
94
+ return 'red';
95
+ }
96
+ if ((selectedRow.workingTree?.staged ?? 0) > 0
97
+ || (selectedRow.workingTree?.unstaged ?? 0) > 0
98
+ || (selectedRow.workingTree?.untracked ?? 0) > 0) {
99
+ return 'yellow';
100
+ }
101
+ return undefined;
102
+ }
103
+ function getNotes(selectedRow) {
104
+ if (selectedRow.invalidReason) {
105
+ return selectedRow.invalidReason;
106
+ }
107
+ if (selectedRow.tags.includes('external')) {
108
+ return 'External worktree managed outside the main checkout path.';
109
+ }
110
+ if (selectedRow.tags.includes('active')) {
111
+ return 'This worktree currently owns the running command session.';
112
+ }
113
+ return 'Ready to launch with the configured command in this worktree.';
114
+ }
115
+ function SectionHeader({ label }) {
116
+ return (_jsxs(Text, { bold: true, color: "cyan", children: ["[", label, "]"] }));
117
+ }
118
+ export function ActionPanel({ selectedRow, activePath, stacked, width, compactDetails, }) {
119
+ if (!selectedRow) {
120
+ return (_jsxs(Box, { width: width, flexGrow: stacked ? 0 : 1, flexShrink: 1, borderStyle: "round", borderColor: "magenta", flexDirection: "column", paddingX: 1, children: [_jsx(Text, { bold: true, color: "magenta", children: "Selection / Action" }), _jsx(Text, { dimColor: true, children: "No worktrees found." })] }));
121
+ }
122
+ const actionMessage = getActionMessage(selectedRow, activePath);
123
+ const showFullPath = !compactDetails && selectedRow.shortPath !== selectedRow.path;
124
+ const showTags = !compactDetails;
125
+ const pullRequestTitle = selectedRow.pullRequest?.kind === 'found' && !compactDetails
126
+ ? sanitizeInlineText(selectedRow.pullRequest.title)
127
+ : null;
128
+ return (_jsxs(Box, { width: width, flexGrow: stacked ? 0 : 1, flexShrink: 1, borderStyle: "round", borderColor: "magenta", flexDirection: "column", paddingX: 1, children: [_jsx(Text, { bold: true, color: "magenta", children: "Selection / Action" }), _jsx(SectionHeader, { label: "Identity" }), _jsxs(Text, { bold: true, color: selectedRow.tags.includes('active') ? 'green' : undefined, wrap: "truncate-end", children: ["Branch: ", sanitizeInlineText(selectedRow.branch)] }), _jsxs(Text, { wrap: "truncate-end", children: ["Path: ", sanitizeInlineText(selectedRow.shortPath)] }), showFullPath ? _jsxs(Text, { wrap: "truncate-end", children: ["Full Path: ", sanitizeInlineText(selectedRow.path)] }) : undefined, _jsxs(Text, { wrap: "truncate-end", children: ["HEAD: ", selectedRow.headSha || '-'] }), showTags ? _jsxs(Text, { wrap: "truncate-end", children: ["Tags: ", formatTags(selectedRow.tags)] }) : undefined, _jsx(SectionHeader, { label: "Git / PR" }), _jsxs(Text, { wrap: "truncate-end", children: ["Upstream: ", formatUpstream(selectedRow)] }), _jsxs(Text, { wrap: "truncate-end", children: ["Status: ", formatWorkingTree(selectedRow)] }), _jsxs(Text, { color: getPullRequestColor(selectedRow), dimColor: selectedRow.pullRequest?.kind === 'found' && selectedRow.pullRequest.state !== 'OPEN', wrap: "truncate-end", children: [getPullRequestLabel(selectedRow), ": ", formatPullRequest(selectedRow)] }), pullRequestTitle ? _jsxs(Text, { dimColor: selectedRow.pullRequest?.kind === 'found' && selectedRow.pullRequest.state !== 'OPEN', wrap: "truncate-end", children: [getPullRequestTitleLabel(selectedRow), ": ", pullRequestTitle] }) : undefined, _jsx(SectionHeader, { label: "Action" }), _jsx(Text, { color: getActionColor(selectedRow), wrap: "truncate-end", children: actionMessage }), _jsx(SectionHeader, { label: "Notes" }), _jsx(Text, { dimColor: true, wrap: "truncate-end", children: getNotes(selectedRow) })] }));
129
+ }
@@ -0,0 +1,5 @@
1
+ import React from 'react';
2
+ import type { AppStatus } from '../core/runtime.js';
3
+ export declare function ContextBar({ status }: {
4
+ status: AppStatus;
5
+ }): React.JSX.Element;
@@ -0,0 +1,12 @@
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ const COLOR_BY_KIND = {
4
+ idle: 'blue',
5
+ starting: 'yellow',
6
+ running: 'green',
7
+ stopping: 'yellow',
8
+ error: 'red',
9
+ };
10
+ export function ContextBar({ status }) {
11
+ return (_jsxs(Box, { borderStyle: "round", borderColor: COLOR_BY_KIND[status.kind], flexDirection: "column", paddingX: 1, children: [_jsxs(Text, { color: COLOR_BY_KIND[status.kind], wrap: "truncate-end", children: ["Status: ", status.kind, " \u2014 ", status.message] }), _jsx(Text, { dimColor: true, wrap: "truncate-end", children: "Keys: \u2191\u2193/jk move g/G first/last Enter start/switch s stop r refresh q quit" })] }));
12
+ }
@@ -0,0 +1,6 @@
1
+ import React from 'react';
2
+ export declare function Header({ repoName, namespace, activeBranch, }: {
3
+ repoName: string;
4
+ namespace: string;
5
+ activeBranch: string | null;
6
+ }): React.JSX.Element;
@@ -0,0 +1,5 @@
1
+ import { jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ export function Header({ repoName, namespace, activeBranch, }) {
4
+ return (_jsxs(Box, { borderStyle: "round", borderColor: "blue", flexDirection: "column", paddingX: 1, children: [_jsxs(Text, { bold: true, color: "blue", wrap: "truncate-end", children: ["Worktree Command TUI \u00B7 Repo: ", repoName] }), _jsxs(Text, { color: "green", wrap: "truncate-end", children: ["Active: ", activeBranch ?? '-'] }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["Namespace: ", namespace] })] }));
5
+ }
@@ -0,0 +1,7 @@
1
+ import type { AppRow } from '../core/runtime.js';
2
+ export declare function WorktreeList({ rows, selectedIndex, width, stacked, }: {
3
+ rows: AppRow[];
4
+ selectedIndex: number;
5
+ width?: number;
6
+ stacked: boolean;
7
+ }): import("react").JSX.Element;
@@ -0,0 +1,58 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ const MIN_BRANCH_WIDTH = 24;
4
+ function sanitizeInlineText(value) {
5
+ return value
6
+ .replace(/[\r\n\t\u2028\u2029]+/g, ' ')
7
+ .replace(/[\u0000-\u001f\u007f-\u009f]/g, '')
8
+ .replace(/\p{Cf}/gu, '')
9
+ .replace(/\s+/g, ' ')
10
+ .trim();
11
+ }
12
+ function getIndicator(row) {
13
+ if (row.tags.includes('active')) {
14
+ return '*';
15
+ }
16
+ if (row.tags.includes('invalid')) {
17
+ return '!';
18
+ }
19
+ if (row.tags.includes('external')) {
20
+ return '^';
21
+ }
22
+ if (row.tags.includes('main')) {
23
+ return '#';
24
+ }
25
+ return '-';
26
+ }
27
+ function getRowColor(row, isSelected) {
28
+ if (row.tags.includes('active')) {
29
+ return 'green';
30
+ }
31
+ if (isSelected) {
32
+ return 'cyan';
33
+ }
34
+ if (row.tags.includes('invalid')) {
35
+ return 'red';
36
+ }
37
+ if (row.tags.includes('external')) {
38
+ return 'yellow';
39
+ }
40
+ if (row.tags.includes('main')) {
41
+ return 'blue';
42
+ }
43
+ return undefined;
44
+ }
45
+ function truncateLabel(value, width) {
46
+ if (value.length <= width) {
47
+ return value;
48
+ }
49
+ return `${value.slice(0, Math.max(width - 1, 0))}…`;
50
+ }
51
+ export function WorktreeList({ rows, selectedIndex, width, stacked, }) {
52
+ const branchWidth = Math.max(MIN_BRANCH_WIDTH, (width ?? 34) - 7);
53
+ return (_jsxs(Box, { width: width, flexGrow: stacked ? 0 : 1, marginRight: stacked ? 0 : 1, borderStyle: "round", borderColor: "cyan", flexDirection: "column", paddingX: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "Worktrees" }), rows.map((row, index) => {
54
+ const isSelected = index === selectedIndex;
55
+ const line = `${isSelected ? '>' : ' '} ${getIndicator(row)} ${truncateLabel(sanitizeInlineText(row.branch), branchWidth)}`;
56
+ return (_jsx(Text, { color: getRowColor(row, isSelected), dimColor: !isSelected && getRowColor(row, isSelected) === undefined, wrap: "truncate-end", children: line }, row.path));
57
+ })] }));
58
+ }
@@ -0,0 +1,10 @@
1
+ export declare function startDetachedCommand({ command, cwd, logsDir, logFileBase, }: {
2
+ command: string[];
3
+ cwd: string;
4
+ logsDir: string;
5
+ logFileBase: string;
6
+ }): Promise<{
7
+ pid: number;
8
+ pgid: number;
9
+ logPath: string;
10
+ }>;
@@ -0,0 +1,38 @@
1
+ import { closeSync, mkdirSync, openSync } from 'node:fs';
2
+ import { spawn } from 'node:child_process';
3
+ import path from 'node:path';
4
+ export function startDetachedCommand({ command, cwd, logsDir, logFileBase, }) {
5
+ mkdirSync(logsDir, { recursive: true });
6
+ const logPath = path.join(logsDir, `${logFileBase}.log`);
7
+ const fd = openSync(logPath, 'a');
8
+ const { promise, resolve, reject } = Promise.withResolvers();
9
+ let settled = false;
10
+ const child = spawn(command[0], command.slice(1), {
11
+ cwd,
12
+ detached: true,
13
+ stdio: ['ignore', fd, fd],
14
+ });
15
+ const finalize = () => {
16
+ if (settled) {
17
+ return;
18
+ }
19
+ settled = true;
20
+ closeSync(fd);
21
+ };
22
+ child.once('error', error => {
23
+ finalize();
24
+ reject(error);
25
+ });
26
+ child.once('spawn', () => {
27
+ const pid = child.pid;
28
+ if (pid === undefined) {
29
+ finalize();
30
+ reject(new Error('spawn succeeded without pid'));
31
+ return;
32
+ }
33
+ child.unref();
34
+ finalize();
35
+ resolve({ pid, pgid: pid, logPath });
36
+ });
37
+ return promise;
38
+ }
@@ -0,0 +1,14 @@
1
+ export declare const CONFIG_FILE_NAME = ".worktree-command-tui.jsonc";
2
+ export declare const LEGACY_CONFIG_FILE_NAME = ".worktree-command-tui.json";
3
+ export declare const CONFIG_FILE_NAMES: readonly [".worktree-command-tui.jsonc", ".worktree-command-tui.json"];
4
+ export interface ToolConfig {
5
+ namespace: string;
6
+ command: string[];
7
+ port: number;
8
+ requiredFiles: string[];
9
+ orphanMatchers: string[];
10
+ }
11
+ export declare function parseJsonc(source: string): unknown;
12
+ export declare function loadToolConfig({ repoRoot }: {
13
+ repoRoot: string;
14
+ }): Promise<ToolConfig>;