clairo 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.
Files changed (3) hide show
  1. package/README.md +254 -0
  2. package/dist/cli.js +902 -0
  3. package/package.json +38 -0
package/README.md ADDED
@@ -0,0 +1,254 @@
1
+ # clairo
2
+
3
+ ## Overview
4
+
5
+ clairo is a terminal-based user interface (TUI) application that consolidates GitHub pull requests, Jira ticket management, and personal daily logging into a single keyboard-driven interface. Built with TypeScript, it auto-detects your current git context and eliminates context-switching between web UIs, preventing tasks from falling through the cracks.
6
+
7
+ ## Problem Statement
8
+
9
+ Software engineers working with GitHub and Jira face constant context-switching:
10
+
11
+ - Opening GitHub web UI to create/manage PRs
12
+ - Navigating to Jira web UI to update ticket statuses
13
+ - Maintaining personal work logs in separate text files
14
+ - Forgetting to update systems (e.g., merged PR but ticket still "In Progress")
15
+
16
+ This fragmented workflow leads to:
17
+
18
+ - Wasted time switching between tools
19
+ - Incomplete/outdated ticket statuses
20
+ - Lost context about what was worked on
21
+ - Mental overhead tracking multiple systems
22
+
23
+ ## Target User
24
+
25
+ Software engineers (particularly full-stack developers) who:
26
+
27
+ - Work with both GitHub and Jira daily
28
+ - Prefer terminal/keyboard-driven workflows
29
+ - Maintain personal work logs
30
+ - Want to reduce context-switching overhead
31
+
32
+ ## User Stories
33
+
34
+ ### Core Workflows
35
+
36
+ 1. **As a developer**, I want clairo to auto-detect my current git branch and repository, so it knows what I'm working on without manual input
37
+ 2. **As a developer**, I want to see if a PR already exists for my current branch, so I don't accidentally create duplicates
38
+ 3. **As a developer**, I want to create GitHub PRs linked to Jira tickets from the TUI, so I don't have to open a browser
39
+ 4. **As a developer**, I want to view and modify existing PRs for my branch, so I can update them without leaving the terminal
40
+ 5. **As a developer**, I want to browse my Jira tickets using saved views/filters, so I can quickly find relevant work
41
+ 6. **As a developer**, I want to maintain daily work logs in markdown, so I have a record of what I accomplished
42
+ 7. **As a developer**, I want optional prompts to log PR/ticket actions, so I can choose when to document my work
43
+ 8. **As a developer**, I want to view previous logs easily, so I can reference what I did on past days
44
+
45
+ ### Quality of Life
46
+
47
+ 9. **As a developer**, I want all navigation to be keyboard-driven with tabs, so I can stay in flow
48
+ 10. **As a developer**, I want the tool to handle GitHub 2FA via PAT, so authentication is seamless
49
+ 11. **As a developer**, I want my credentials stored securely locally, so I don't re-authenticate constantly
50
+ 12. **As a developer**, I want clairo to handle multiple PRs from the same branch, so I can work with different base branches
51
+
52
+ ## Features
53
+
54
+ ### MVP (v1.0)
55
+
56
+ #### Core Interface Structure
57
+
58
+ clairo uses a tab-based navigation system with three main tabs:
59
+
60
+ **Tab 1: PR View**
61
+
62
+ - Auto-detects current git branch and repository (via git remote parsing)
63
+ - Shows different states:
64
+ - **No git repo**: Display "Not in a git repository" message
65
+ - **No PR exists**: Show "Create PR" interface
66
+ - **Single PR exists**: Display PR details with edit capabilities
67
+ - **Multiple PRs exist**: List all PRs from current branch (different base branches), allow selection
68
+ - Create PR flow:
69
+ - Pre-populate branch information
70
+ - Link to Jira ticket (searchable dropdown)
71
+ - Title and description fields
72
+ - Optional: auto-fill title/description from selected Jira ticket
73
+ - View/Edit PR:
74
+ - PR title, description, status
75
+ - CI/CD checks status
76
+ - Reviewers
77
+ - Option to update PR details
78
+
79
+ **Tab 2: Jira View**
80
+
81
+ - Display saved Jira views/filters (configured in setup)
82
+ - Browse tickets in a scrollable list (title, key, status, assignee)
83
+ - Select ticket to view details:
84
+ - Full description
85
+ - Current status
86
+ - Comments (read-only for v1)
87
+ - Available transitions
88
+ - Update ticket status (transition to different states)
89
+ - Search/filter within current view
90
+
91
+ **Tab 3: Logs View**
92
+
93
+ - Split-pane layout:
94
+ - **Left pane**: Today's log file (editable markdown)
95
+ - **Right pane**: List of previous log files (by date)
96
+ - Navigate to previous days by selecting from right pane
97
+ - Auto-create today's log if it doesn't exist
98
+ - Optional prompt after PR/ticket actions: "Log this? (y/n)"
99
+ - Auto-append timestamped entries to today's log
100
+ - Format: `## HH:MM - Action description`
101
+
102
+ #### Git Context Detection
103
+
104
+ - Parse `git remote get-url origin` to extract owner/repo
105
+ - Fallback handling:
106
+ - If `origin` doesn't exist, list all remotes
107
+ - Filter for GitHub URLs
108
+ - Prompt user to select if multiple GitHub remotes exist
109
+ - Cache repo context per session to avoid repeated parsing
110
+ - Handle non-GitHub repositories gracefully
111
+
112
+ #### GitHub Integration
113
+
114
+ - Query PRs by head branch: `GET /repos/{owner}/{repo}/pulls?head={user}:{branch}&state=open`
115
+ - Create new PRs: `POST /repos/{owner}/{repo}/pulls`
116
+ - Update PR details: `PATCH /repos/{owner}/{repo}/pulls/{number}`
117
+ - Fetch PR status, checks, and reviewers
118
+ - Handle authentication via Personal Access Token (PAT)
119
+
120
+ #### Jira Integration
121
+
122
+ - Authenticate via API token
123
+ - Fetch saved views/filters (JQL queries configured in setup)
124
+ - Search issues: `GET /rest/api/3/search`
125
+ - Get issue details: `GET /rest/api/3/issue/{issueKey}`
126
+ - Get available transitions: `GET /rest/api/3/issue/{issueKey}/transitions`
127
+ - Transition issue: `POST /rest/api/3/issue/{issueKey}/transitions`
128
+ - **Note**: Transition IDs are fetched dynamically (workflow-dependent)
129
+
130
+ #### Daily Logging
131
+
132
+ - Markdown files stored at `~/.clairo/logs/YYYY-MM-DD.md`
133
+ - Auto-create file for current day if missing
134
+ - Simple text editor within TUI (Ink text input component)
135
+ - Optional auto-logging after actions:
136
+
137
+ - Prompt: "Log this? (y/n)"
138
+ - Auto-generated format:
139
+
140
+ ```markdown
141
+ ## 14:23 - Created PR #234
142
+
143
+ Fixed auth bug in user service
144
+ Jira: PROJ-123
145
+ ```
146
+
147
+ - Navigate historical logs via right pane
148
+
149
+ #### Configuration & Setup
150
+
151
+ - First-run setup wizard:
152
+ - GitHub PAT creation guide
153
+ - Jira API token creation guide
154
+ - Jira instance URL
155
+ - Configure Jira saved views (JQL queries)
156
+ - Set default auto-log behavior (prompt/always/never)
157
+ - Configuration stored at `~/.clairo/config.json`
158
+ - Settings accessible via settings menu in TUI
159
+
160
+ ### Future Enhancements (Post-MVP)
161
+
162
+ - **CLI quick actions**: `clairo pr create`, `clairo ticket update PROJ-123 done`
163
+ - Notifications for PR review requests
164
+ - PR templates
165
+ - Inline PR review workflow
166
+ - GitHub Actions status visualization
167
+ - Batch ticket operations
168
+ - Export logs to different formats
169
+ - Customizable keybindings
170
+ - Support for GitLab, Bitbucket
171
+ - Support for Linear, Asana
172
+ - Multi-repo workspace mode
173
+ - Offline mode with cache
174
+
175
+ ## Technical Architecture
176
+
177
+ ### Tech Stack
178
+
179
+ - **Language**: TypeScript
180
+ - **TUI Framework**: Ink (React for CLIs)
181
+ - **GitHub API**: `@octokit/rest`
182
+ - **Jira API**: `axios` or `node-fetch` (direct REST calls)
183
+ - **Configuration**: `conf` package or custom JSON file handling
184
+ - **File System**: Node.js built-in `fs/promises`
185
+
186
+ ### Data Storage
187
+
188
+ ```
189
+ ~/.clairo/
190
+ ├── config.json # API tokens, settings, preferences
191
+ └── logs/
192
+ ├── 2026-02-07.md # Daily log files
193
+ ├── 2026-02-06.md
194
+ └── ...
195
+ ```
196
+
197
+ ### API Integration Details
198
+
199
+ #### Git Context Detection
200
+
201
+ - Parse git remote URL using `git remote get-url origin`
202
+ - Extract owner/repo from URLs:
203
+ - HTTPS: `https://github.com/owner/repo.git`
204
+ - SSH: `git@github.com:owner/repo.git`
205
+ - Fallback strategy:
206
+ 1. Try `origin` remote first
207
+ 2. If not found, run `git remote` to list all remotes
208
+ 3. Filter for GitHub URLs
209
+ 4. If multiple GitHub remotes, prompt user to select
210
+ 5. Cache selection for current session
211
+ - Get current branch: `git rev-parse --abbrev-ref HEAD`
212
+
213
+ #### GitHub
214
+
215
+ - Authentication: Personal Access Token (PAT)
216
+ - Permissions needed: `repo` scope
217
+ - Key endpoints:
218
+ - List PRs by branch: `GET /repos/{owner}/{repo}/pulls?head={user}:{branch}&state=open`
219
+ - Create PR: `POST /repos/{owner}/{repo}/pulls`
220
+ - Get PR details: `GET /repos/{owner}/{repo}/pulls/{number}`
221
+ - Update PR: `PATCH /repos/{owner}/{repo}/pulls/{number}`
222
+
223
+ #### Jira
224
+
225
+ - Authentication: API Token
226
+ - Key endpoints:
227
+ - Search issues (using JQL): `GET /rest/api/3/search?jql={query}`
228
+ - Get issue: `GET /rest/api/3/issue/{issueKey}`
229
+ - Get transitions: `GET /rest/api/3/issue/{issueKey}/transitions`
230
+ - Transition issue: `POST /rest/api/3/issue/{issueKey}/transitions`
231
+
232
+ **Note**: Jira transition IDs vary by workflow configuration and must be fetched dynamically.
233
+
234
+ ### Security Considerations
235
+
236
+ - Store credentials in `~/.clairo/config.json` with restrictive file permissions (0600)
237
+ - Never log tokens/credentials
238
+ - Consider using system keychain in future iterations
239
+ - # Validate all user inputs before API calls
240
+
241
+ ### Important (High Priority)
242
+
243
+ 3. **Jira Saved Views Configuration**: How should users configure their saved views during setup? Manual JQL entry, or UI to build queries?
244
+ 4. **PR Title Auto-fill**: When creating a PR linked to a Jira ticket, should title come from ticket title, ticket key + title, or be fully manual?
245
+ 5. **Multiple PRs Handling**: When multiple PRs exist for a branch, should there be a "primary" PR concept, or treat all equally?
246
+ 6. **Log Entry Format**: Finalize fields for auto-generated entries (currently: timestamp, action, PR #, Jira key)
247
+
248
+ ## Out of Scope (v1.0)
249
+
250
+ - CLI quick actions (saved for v2)
251
+ - Real-time notifications
252
+ - Inline code review within TUI
253
+ - Comment creation on Jira tickets (read-only for v1)
254
+ - PR merge functionality (use `gh` CLI or web UI)
package/dist/cli.js ADDED
@@ -0,0 +1,902 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.tsx
4
+ import meow from "meow";
5
+
6
+ // src/app.tsx
7
+ import { Box as Box7 } from "ink";
8
+
9
+ // src/components/github/GitHubView.tsx
10
+ import { useCallback, useEffect as useEffect3, useState as useState4 } from "react";
11
+ import { TitledBox as TitledBox4 } from "@mishieck/ink-titled-box";
12
+ import { Box as Box5, Text as Text5, useInput as useInput4 } from "ink";
13
+
14
+ // src/lib/config/index.ts
15
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
16
+ import { homedir } from "os";
17
+ import { dirname, join } from "path";
18
+ var CONFIG_PATH = join(homedir(), ".clairo", "config.json");
19
+ var DEFAULT_CONFIG = {};
20
+ function loadConfig() {
21
+ try {
22
+ if (!existsSync(CONFIG_PATH)) {
23
+ return DEFAULT_CONFIG;
24
+ }
25
+ const content = readFileSync(CONFIG_PATH, "utf-8");
26
+ return JSON.parse(content);
27
+ } catch {
28
+ return DEFAULT_CONFIG;
29
+ }
30
+ }
31
+ function saveConfig(config) {
32
+ const dir = dirname(CONFIG_PATH);
33
+ if (!existsSync(dir)) {
34
+ mkdirSync(dir, { recursive: true });
35
+ }
36
+ writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
37
+ }
38
+
39
+ // src/lib/github/config.ts
40
+ function getRepoConfig(repoPath) {
41
+ const config = loadConfig();
42
+ const repos = config.repositories ?? {};
43
+ return repos[repoPath] ?? {};
44
+ }
45
+ function updateRepoConfig(repoPath, updates) {
46
+ const config = loadConfig();
47
+ if (!config.repositories) {
48
+ config.repositories = {};
49
+ }
50
+ config.repositories[repoPath] = {
51
+ ...config.repositories[repoPath],
52
+ ...updates
53
+ };
54
+ saveConfig(config);
55
+ }
56
+ function getSelectedRemote(repoPath, availableRemotes) {
57
+ const repoConfig = getRepoConfig(repoPath);
58
+ if (repoConfig.selectedRemote && availableRemotes.includes(repoConfig.selectedRemote)) {
59
+ return repoConfig.selectedRemote;
60
+ }
61
+ if (availableRemotes.includes("origin")) {
62
+ return "origin";
63
+ }
64
+ return availableRemotes[0] ?? null;
65
+ }
66
+
67
+ // src/lib/github/git.ts
68
+ import { execSync } from "child_process";
69
+ function isGitRepo() {
70
+ try {
71
+ execSync("git rev-parse --is-inside-work-tree", {
72
+ encoding: "utf-8",
73
+ stdio: ["pipe", "pipe", "pipe"]
74
+ });
75
+ return true;
76
+ } catch {
77
+ return false;
78
+ }
79
+ }
80
+ function getRepoRoot() {
81
+ try {
82
+ const root = execSync("git rev-parse --show-toplevel", {
83
+ encoding: "utf-8",
84
+ stdio: ["pipe", "pipe", "pipe"]
85
+ }).trim();
86
+ return { success: true, data: root };
87
+ } catch {
88
+ return { success: false, error: "Not a git repository" };
89
+ }
90
+ }
91
+ function listRemotes() {
92
+ try {
93
+ const output = execSync("git remote -v", {
94
+ encoding: "utf-8",
95
+ stdio: ["pipe", "pipe", "pipe"]
96
+ });
97
+ const remotes = [];
98
+ const seen = /* @__PURE__ */ new Set();
99
+ for (const line of output.trim().split("\n")) {
100
+ if (!line) continue;
101
+ const match = line.match(/^(\S+)\s+(\S+)\s+\((fetch|push)\)$/);
102
+ if (match && match[3] === "fetch" && !seen.has(match[1])) {
103
+ seen.add(match[1]);
104
+ remotes.push({ name: match[1], url: match[2] });
105
+ }
106
+ }
107
+ return { success: true, data: remotes };
108
+ } catch {
109
+ return { success: false, error: "Failed to list remotes" };
110
+ }
111
+ }
112
+ function getCurrentBranch() {
113
+ try {
114
+ const branch = execSync("git branch --show-current", {
115
+ encoding: "utf-8",
116
+ stdio: ["pipe", "pipe", "pipe"]
117
+ }).trim();
118
+ if (!branch) {
119
+ return { success: false, error: "Detached HEAD state" };
120
+ }
121
+ return { success: true, data: branch };
122
+ } catch {
123
+ return { success: false, error: "Failed to get current branch" };
124
+ }
125
+ }
126
+
127
+ // src/lib/github/index.ts
128
+ import { exec } from "child_process";
129
+ import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
130
+ import { join as join2 } from "path";
131
+ import { promisify } from "util";
132
+ var execAsync = promisify(exec);
133
+ async function isGhInstalled() {
134
+ try {
135
+ await execAsync("gh --version");
136
+ return true;
137
+ } catch {
138
+ return false;
139
+ }
140
+ }
141
+ async function isGhAuthenticated() {
142
+ try {
143
+ await execAsync("gh auth status");
144
+ return true;
145
+ } catch {
146
+ return false;
147
+ }
148
+ }
149
+ function getRepoFromRemote(remoteUrl) {
150
+ const sshMatch = remoteUrl.match(/git@github\.com:(.+?)(?:\.git)?$/);
151
+ if (sshMatch) return sshMatch[1].replace(/\.git$/, "");
152
+ const httpsMatch = remoteUrl.match(
153
+ /https:\/\/github\.com\/(.+?)(?:\.git)?$/
154
+ );
155
+ if (httpsMatch) return httpsMatch[1].replace(/\.git$/, "");
156
+ return null;
157
+ }
158
+ async function listPRsForBranch(branch, repo) {
159
+ if (!await isGhInstalled()) {
160
+ return {
161
+ success: false,
162
+ error: "GitHub CLI (gh) is not installed. Install from https://cli.github.com",
163
+ errorType: "not_installed"
164
+ };
165
+ }
166
+ if (!await isGhAuthenticated()) {
167
+ return {
168
+ success: false,
169
+ error: "Not authenticated. Run 'gh auth login' to authenticate.",
170
+ errorType: "not_authenticated"
171
+ };
172
+ }
173
+ try {
174
+ const fields = "number,title,state,author,createdAt,isDraft";
175
+ const { stdout } = await execAsync(
176
+ `gh pr list --head "${branch}" --json ${fields} --repo "${repo}"`
177
+ );
178
+ const prs = JSON.parse(stdout);
179
+ return { success: true, data: prs };
180
+ } catch {
181
+ return { success: false, error: "Failed to fetch PRs", errorType: "api_error" };
182
+ }
183
+ }
184
+ async function getPRDetails(prNumber, repo) {
185
+ if (!await isGhInstalled()) {
186
+ return {
187
+ success: false,
188
+ error: "GitHub CLI (gh) is not installed",
189
+ errorType: "not_installed"
190
+ };
191
+ }
192
+ if (!await isGhAuthenticated()) {
193
+ return {
194
+ success: false,
195
+ error: "Not authenticated. Run 'gh auth login'",
196
+ errorType: "not_authenticated"
197
+ };
198
+ }
199
+ try {
200
+ const fields = [
201
+ "number",
202
+ "title",
203
+ "body",
204
+ "state",
205
+ "author",
206
+ "createdAt",
207
+ "updatedAt",
208
+ "isDraft",
209
+ "mergeable",
210
+ "reviewDecision",
211
+ "commits",
212
+ "assignees",
213
+ "reviewRequests",
214
+ "reviews",
215
+ "statusCheckRollup"
216
+ ].join(",");
217
+ const { stdout } = await execAsync(
218
+ `gh pr view ${prNumber} --json ${fields} --repo "${repo}"`
219
+ );
220
+ const pr = JSON.parse(stdout);
221
+ return { success: true, data: pr };
222
+ } catch {
223
+ return {
224
+ success: false,
225
+ error: "Failed to fetch PR details",
226
+ errorType: "api_error"
227
+ };
228
+ }
229
+ }
230
+ function getPRTemplate(repoPath) {
231
+ const templatePaths = [
232
+ ".github/PULL_REQUEST_TEMPLATE.md",
233
+ ".github/pull_request_template.md",
234
+ "PULL_REQUEST_TEMPLATE.md",
235
+ "pull_request_template.md",
236
+ "docs/PULL_REQUEST_TEMPLATE.md"
237
+ ];
238
+ for (const templatePath of templatePaths) {
239
+ const fullPath = join2(repoPath, templatePath);
240
+ if (existsSync2(fullPath)) {
241
+ try {
242
+ return readFileSync2(fullPath, "utf-8");
243
+ } catch {
244
+ continue;
245
+ }
246
+ }
247
+ }
248
+ return null;
249
+ }
250
+ async function createPR(repo, title, body, baseBranch) {
251
+ if (!await isGhInstalled()) {
252
+ return {
253
+ success: false,
254
+ error: "GitHub CLI (gh) is not installed",
255
+ errorType: "not_installed"
256
+ };
257
+ }
258
+ if (!await isGhAuthenticated()) {
259
+ return {
260
+ success: false,
261
+ error: "Not authenticated. Run 'gh auth login'",
262
+ errorType: "not_authenticated"
263
+ };
264
+ }
265
+ try {
266
+ const baseArg = baseBranch ? `--base "${baseBranch}"` : "";
267
+ const fields = "number,title,state,author,createdAt,isDraft";
268
+ const escapedTitle = title.replace(/"/g, '\\"');
269
+ const escapedBody = body.replace(/"/g, '\\"');
270
+ const { stdout } = await execAsync(
271
+ `gh pr create --title "${escapedTitle}" --body "${escapedBody}" ${baseArg} --repo "${repo}" --json ${fields}`
272
+ );
273
+ const pr = JSON.parse(stdout);
274
+ return { success: true, data: pr };
275
+ } catch (err) {
276
+ const message = err instanceof Error ? err.message : "Failed to create PR";
277
+ return {
278
+ success: false,
279
+ error: message,
280
+ errorType: "api_error"
281
+ };
282
+ }
283
+ }
284
+
285
+ // src/components/github/CreatePRModal.tsx
286
+ import { spawnSync } from "child_process";
287
+ import { mkdtempSync, readFileSync as readFileSync3, rmSync, writeFileSync as writeFileSync2 } from "fs";
288
+ import { tmpdir } from "os";
289
+ import { join as join3 } from "path";
290
+ import { useState } from "react";
291
+ import { Box, Text, useInput } from "ink";
292
+ import { jsx, jsxs } from "react/jsx-runtime";
293
+ function openInEditor(content, filename) {
294
+ const editor = process.env.VISUAL || process.env.EDITOR || "vi";
295
+ const tempDir = mkdtempSync(join3(tmpdir(), "clairo-"));
296
+ const tempFile = join3(tempDir, filename);
297
+ try {
298
+ writeFileSync2(tempFile, content);
299
+ const result = spawnSync(editor, [tempFile], {
300
+ stdio: "inherit"
301
+ });
302
+ process.stdout.write("\x1B[2J\x1B[H");
303
+ if (result.status !== 0) {
304
+ return null;
305
+ }
306
+ return readFileSync3(tempFile, "utf-8");
307
+ } finally {
308
+ try {
309
+ rmSync(tempDir, { recursive: true });
310
+ } catch {
311
+ }
312
+ }
313
+ }
314
+ function CreatePRModal({ template, onSubmit, onCancel, loading, error }) {
315
+ const [title, setTitle] = useState("");
316
+ const [body, setBody] = useState(template ?? "");
317
+ const [selectedItem, setSelectedItem] = useState("title");
318
+ const items = ["title", "body", "submit"];
319
+ useInput(
320
+ (input, key) => {
321
+ if (loading) return;
322
+ if (key.escape) {
323
+ onCancel();
324
+ return;
325
+ }
326
+ if (key.upArrow || input === "k") {
327
+ setSelectedItem((prev) => {
328
+ const idx = items.indexOf(prev);
329
+ return items[Math.max(0, idx - 1)];
330
+ });
331
+ return;
332
+ }
333
+ if (key.downArrow || input === "j") {
334
+ setSelectedItem((prev) => {
335
+ const idx = items.indexOf(prev);
336
+ return items[Math.min(items.length - 1, idx + 1)];
337
+ });
338
+ return;
339
+ }
340
+ if (key.return) {
341
+ if (selectedItem === "title") {
342
+ const newTitle = openInEditor(title, "PR_TITLE.txt");
343
+ if (newTitle !== null) {
344
+ setTitle(newTitle.split("\n")[0].trim());
345
+ }
346
+ } else if (selectedItem === "body") {
347
+ const newBody = openInEditor(body, "PR_DESCRIPTION.md");
348
+ if (newBody !== null) {
349
+ setBody(newBody);
350
+ }
351
+ } else if (selectedItem === "submit") {
352
+ if (title.trim()) {
353
+ onSubmit(title.trim(), body);
354
+ }
355
+ }
356
+ }
357
+ },
358
+ { isActive: !loading }
359
+ );
360
+ const renderItem = (item, label, value) => {
361
+ const isSelected = selectedItem === item;
362
+ const prefix = isSelected ? "> " : " ";
363
+ const color = isSelected ? "cyan" : void 0;
364
+ return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
365
+ /* @__PURE__ */ jsxs(Text, { color, bold: isSelected, children: [
366
+ prefix,
367
+ label
368
+ ] }),
369
+ value !== void 0 && /* @__PURE__ */ jsx(Box, { marginLeft: 4, children: /* @__PURE__ */ jsx(Text, { dimColor: true, children: value || "(empty - press Enter to edit)" }) })
370
+ ] });
371
+ };
372
+ const truncatedBody = body ? body.split("\n").slice(0, 2).join(" ").slice(0, 60) + (body.length > 60 ? "..." : "") : "";
373
+ return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, paddingY: 1, children: [
374
+ /* @__PURE__ */ jsx(Text, { bold: true, color: "cyan", children: "Create Pull Request" }),
375
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: "Up/Down to select, Enter to edit, Esc to cancel" }),
376
+ /* @__PURE__ */ jsx(Box, { marginTop: 1 }),
377
+ error && /* @__PURE__ */ jsx(Box, { marginBottom: 1, children: /* @__PURE__ */ jsx(Text, { color: "red", children: error }) }),
378
+ renderItem("title", "Title", title),
379
+ /* @__PURE__ */ jsx(Box, { marginTop: 1 }),
380
+ renderItem("body", "Description", truncatedBody),
381
+ /* @__PURE__ */ jsx(Box, { marginTop: 1 }),
382
+ /* @__PURE__ */ jsx(Box, { children: /* @__PURE__ */ jsxs(Text, { color: selectedItem === "submit" ? "green" : void 0, bold: selectedItem === "submit", children: [
383
+ selectedItem === "submit" ? "> " : " ",
384
+ title.trim() ? "[Submit PR]" : "[Enter title first]"
385
+ ] }) }),
386
+ loading && /* @__PURE__ */ jsx(Box, { marginTop: 1, children: /* @__PURE__ */ jsx(Text, { color: "yellow", children: "Creating PR..." }) })
387
+ ] });
388
+ }
389
+
390
+ // src/components/github/PRDetailsBox.tsx
391
+ import { TitledBox } from "@mishieck/ink-titled-box";
392
+ import { Box as Box2, Text as Text2 } from "ink";
393
+ import { Fragment, jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
394
+ function getCheckColor(check) {
395
+ const conclusion = check.conclusion ?? check.state;
396
+ if (conclusion === "SUCCESS") return "green";
397
+ if (conclusion === "FAILURE" || conclusion === "ERROR") return "red";
398
+ if (conclusion === "SKIPPED" || conclusion === "NEUTRAL") return "gray";
399
+ if (conclusion === "PENDING" || check.status === "IN_PROGRESS" || check.status === "QUEUED" || check.status === "WAITING")
400
+ return "yellow";
401
+ if (check.status === "COMPLETED") return "green";
402
+ return void 0;
403
+ }
404
+ function getCheckIcon(check) {
405
+ const conclusion = check.conclusion ?? check.state;
406
+ if (conclusion === "SUCCESS") return "\u2713";
407
+ if (conclusion === "FAILURE" || conclusion === "ERROR") return "\u2717";
408
+ if (conclusion === "SKIPPED" || conclusion === "NEUTRAL") return "\u25CB";
409
+ if (conclusion === "PENDING" || check.status === "IN_PROGRESS" || check.status === "QUEUED" || check.status === "WAITING")
410
+ return "\u25CF";
411
+ if (check.status === "COMPLETED") return "\u2713";
412
+ return "?";
413
+ }
414
+ function PRDetailsBox({ pr, loading, error, isFocused }) {
415
+ var _a, _b, _c, _d, _e, _f;
416
+ const title = "3 PR Details";
417
+ const borderColor = isFocused ? "cyan" : void 0;
418
+ const displayTitle = pr ? `${title} - #${pr.number}` : title;
419
+ const reviewStatus = (pr == null ? void 0 : pr.reviewDecision) ?? "PENDING";
420
+ const reviewColor = reviewStatus === "APPROVED" ? "green" : reviewStatus === "CHANGES_REQUESTED" ? "red" : "yellow";
421
+ const mergeableColor = (pr == null ? void 0 : pr.mergeable) === "MERGEABLE" ? "green" : (pr == null ? void 0 : pr.mergeable) === "CONFLICTING" ? "red" : "yellow";
422
+ return /* @__PURE__ */ jsx2(TitledBox, { borderStyle: "round", titles: [displayTitle], borderColor, flexGrow: 2, children: /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", paddingX: 1, children: [
423
+ loading && /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: "Loading details..." }),
424
+ error && /* @__PURE__ */ jsx2(Text2, { color: "red", children: error }),
425
+ !loading && !error && !pr && /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: "Select a PR to view details" }),
426
+ !loading && !error && pr && /* @__PURE__ */ jsxs2(Fragment, { children: [
427
+ /* @__PURE__ */ jsx2(Text2, { bold: true, children: pr.title }),
428
+ /* @__PURE__ */ jsxs2(Text2, { dimColor: true, children: [
429
+ "by ",
430
+ ((_a = pr.author) == null ? void 0 : _a.login) ?? "unknown",
431
+ " | ",
432
+ ((_b = pr.commits) == null ? void 0 : _b.length) ?? 0,
433
+ " commits"
434
+ ] }),
435
+ /* @__PURE__ */ jsxs2(Box2, { marginTop: 1, children: [
436
+ /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: "Review: " }),
437
+ /* @__PURE__ */ jsx2(Text2, { color: reviewColor, children: reviewStatus }),
438
+ /* @__PURE__ */ jsx2(Text2, { children: " | " }),
439
+ /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: "Merge: " }),
440
+ /* @__PURE__ */ jsx2(Text2, { color: mergeableColor, children: pr.mergeable ?? "UNKNOWN" })
441
+ ] }),
442
+ (((_c = pr.assignees) == null ? void 0 : _c.length) ?? 0) > 0 && /* @__PURE__ */ jsxs2(Box2, { marginTop: 1, children: [
443
+ /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: "Assignees: " }),
444
+ /* @__PURE__ */ jsx2(Text2, { children: pr.assignees.map((a) => a.login).join(", ") })
445
+ ] }),
446
+ (((_d = pr.reviewRequests) == null ? void 0 : _d.length) ?? 0) > 0 && /* @__PURE__ */ jsxs2(Box2, { children: [
447
+ /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: "Reviewers: " }),
448
+ /* @__PURE__ */ jsx2(Text2, { children: pr.reviewRequests.map((r) => r.login ?? r.name ?? r.slug ?? "Team").join(", ") })
449
+ ] }),
450
+ (((_e = pr.statusCheckRollup) == null ? void 0 : _e.length) ?? 0) > 0 && /* @__PURE__ */ jsxs2(Box2, { marginTop: 1, flexDirection: "column", children: [
451
+ /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: "Checks:" }),
452
+ (_f = pr.statusCheckRollup) == null ? void 0 : _f.map((check, idx) => /* @__PURE__ */ jsxs2(Text2, { color: getCheckColor(check), children: [
453
+ " ",
454
+ getCheckIcon(check),
455
+ " ",
456
+ check.name ?? check.context
457
+ ] }, idx))
458
+ ] }),
459
+ pr.body && /* @__PURE__ */ jsxs2(Box2, { marginTop: 1, flexDirection: "column", children: [
460
+ /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: "Description:" }),
461
+ /* @__PURE__ */ jsx2(Text2, { children: pr.body })
462
+ ] })
463
+ ] })
464
+ ] }) });
465
+ }
466
+
467
+ // src/components/github/PullRequestsBox.tsx
468
+ import { useEffect, useState as useState2 } from "react";
469
+ import { TitledBox as TitledBox2 } from "@mishieck/ink-titled-box";
470
+ import { Box as Box3, Text as Text3, useInput as useInput2 } from "ink";
471
+ import { Fragment as Fragment2, jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
472
+ function PullRequestsBox({
473
+ prs,
474
+ selectedPR,
475
+ onSelect,
476
+ onCreatePR,
477
+ loading,
478
+ error,
479
+ branch,
480
+ isFocused
481
+ }) {
482
+ const [highlightedIndex, setHighlightedIndex] = useState2(0);
483
+ const totalItems = prs.length + 1;
484
+ useEffect(() => {
485
+ const idx = prs.findIndex((p) => p.number === (selectedPR == null ? void 0 : selectedPR.number));
486
+ if (idx >= 0) setHighlightedIndex(idx);
487
+ }, [selectedPR, prs]);
488
+ useInput2(
489
+ (input, key) => {
490
+ if (!isFocused) return;
491
+ if (key.upArrow || input === "k") {
492
+ setHighlightedIndex((prev) => Math.max(0, prev - 1));
493
+ }
494
+ if (key.downArrow || input === "j") {
495
+ setHighlightedIndex((prev) => Math.min(totalItems - 1, prev + 1));
496
+ }
497
+ if (key.return) {
498
+ if (highlightedIndex === prs.length) {
499
+ onCreatePR();
500
+ } else if (prs[highlightedIndex]) {
501
+ onSelect(prs[highlightedIndex]);
502
+ }
503
+ }
504
+ },
505
+ { isActive: isFocused }
506
+ );
507
+ const title = "2 Pull Requests";
508
+ const subtitle = branch ? ` (${branch})` : "";
509
+ const borderColor = isFocused ? "cyan" : void 0;
510
+ return /* @__PURE__ */ jsx3(TitledBox2, { borderStyle: "round", titles: [`${title}${subtitle}`], borderColor, children: /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", paddingX: 1, children: [
511
+ loading && /* @__PURE__ */ jsx3(Text3, { dimColor: true, children: "Loading PRs..." }),
512
+ error && /* @__PURE__ */ jsx3(Text3, { color: "red", children: error }),
513
+ !loading && !error && /* @__PURE__ */ jsxs3(Fragment2, { children: [
514
+ prs.length === 0 && /* @__PURE__ */ jsx3(Text3, { dimColor: true, children: "No PRs for this branch" }),
515
+ prs.map((pr, idx) => {
516
+ const isHighlighted = isFocused && idx === highlightedIndex;
517
+ const isSelected = pr.number === (selectedPR == null ? void 0 : selectedPR.number);
518
+ const prefix = isHighlighted ? "> " : isSelected ? "\u25CF " : " ";
519
+ return /* @__PURE__ */ jsxs3(Text3, { color: isSelected ? "green" : void 0, children: [
520
+ prefix,
521
+ "#",
522
+ pr.number,
523
+ " ",
524
+ pr.isDraft ? "[Draft] " : "",
525
+ pr.title
526
+ ] }, pr.number);
527
+ }),
528
+ /* @__PURE__ */ jsxs3(Text3, { color: "blue", children: [
529
+ isFocused && highlightedIndex === prs.length ? "> " : " ",
530
+ "+ Create new PR"
531
+ ] })
532
+ ] })
533
+ ] }) });
534
+ }
535
+
536
+ // src/components/github/RemotesBox.tsx
537
+ import { useEffect as useEffect2, useState as useState3 } from "react";
538
+ import { TitledBox as TitledBox3 } from "@mishieck/ink-titled-box";
539
+ import { Box as Box4, Text as Text4, useInput as useInput3 } from "ink";
540
+ import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
541
+ function RemotesBox({ remotes, selectedRemote, onSelect, loading, error, isFocused }) {
542
+ const [highlightedIndex, setHighlightedIndex] = useState3(0);
543
+ useEffect2(() => {
544
+ const idx = remotes.findIndex((r) => r.name === selectedRemote);
545
+ if (idx >= 0) setHighlightedIndex(idx);
546
+ }, [selectedRemote, remotes]);
547
+ useInput3(
548
+ (input, key) => {
549
+ if (!isFocused || remotes.length === 0) return;
550
+ if (key.upArrow || input === "k") {
551
+ setHighlightedIndex((prev) => Math.max(0, prev - 1));
552
+ }
553
+ if (key.downArrow || input === "j") {
554
+ setHighlightedIndex((prev) => Math.min(remotes.length - 1, prev + 1));
555
+ }
556
+ if (key.return) {
557
+ onSelect(remotes[highlightedIndex].name);
558
+ }
559
+ },
560
+ { isActive: isFocused }
561
+ );
562
+ const title = "1 Remotes";
563
+ const borderColor = isFocused ? "cyan" : void 0;
564
+ return /* @__PURE__ */ jsx4(TitledBox3, { borderStyle: "round", titles: [title], borderColor, children: /* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", paddingX: 1, children: [
565
+ loading && /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: "Loading..." }),
566
+ error && /* @__PURE__ */ jsx4(Text4, { color: "red", children: error }),
567
+ !loading && !error && remotes.length === 0 && /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: "No remotes configured" }),
568
+ !loading && !error && remotes.map((remote, idx) => {
569
+ const isHighlighted = isFocused && idx === highlightedIndex;
570
+ const isSelected = remote.name === selectedRemote;
571
+ const prefix = isHighlighted ? "> " : isSelected ? "\u25CF " : " ";
572
+ return /* @__PURE__ */ jsxs4(Text4, { color: isSelected ? "green" : void 0, children: [
573
+ prefix,
574
+ remote.name,
575
+ " (",
576
+ remote.url,
577
+ ")"
578
+ ] }, remote.name);
579
+ })
580
+ ] }) });
581
+ }
582
+
583
+ // src/components/github/GitHubView.tsx
584
+ import { jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
585
+ function GitHubView() {
586
+ const [isRepo, setIsRepo] = useState4(null);
587
+ const [repoPath, setRepoPath] = useState4(null);
588
+ const [remotes, setRemotes] = useState4([]);
589
+ const [currentBranch, setCurrentBranch] = useState4(null);
590
+ const [currentRepoSlug, setCurrentRepoSlug] = useState4(null);
591
+ const [selectedRemote, setSelectedRemote] = useState4(null);
592
+ const [selectedPR, setSelectedPR] = useState4(null);
593
+ const [prs, setPrs] = useState4([]);
594
+ const [prDetails, setPrDetails] = useState4(null);
595
+ const [loading, setLoading] = useState4({
596
+ remotes: true,
597
+ prs: false,
598
+ details: false,
599
+ createPR: false
600
+ });
601
+ const [errors, setErrors] = useState4({});
602
+ const [showCreatePR, setShowCreatePR] = useState4(false);
603
+ const [prTemplate, setPrTemplate] = useState4(null);
604
+ const [focusedBox, setFocusedBox] = useState4("remotes");
605
+ useEffect3(() => {
606
+ const gitRepoCheck = isGitRepo();
607
+ setIsRepo(gitRepoCheck);
608
+ if (!gitRepoCheck) {
609
+ setLoading((prev) => ({ ...prev, remotes: false }));
610
+ setErrors((prev) => ({ ...prev, remotes: "Not a git repository" }));
611
+ return;
612
+ }
613
+ const rootResult = getRepoRoot();
614
+ if (rootResult.success) {
615
+ setRepoPath(rootResult.data);
616
+ const template = getPRTemplate(rootResult.data);
617
+ setPrTemplate(template);
618
+ }
619
+ const branchResult = getCurrentBranch();
620
+ if (branchResult.success) {
621
+ setCurrentBranch(branchResult.data);
622
+ }
623
+ const remotesResult = listRemotes();
624
+ if (remotesResult.success) {
625
+ setRemotes(remotesResult.data);
626
+ const remoteNames = remotesResult.data.map((r) => r.name);
627
+ const defaultRemote = getSelectedRemote(rootResult.success ? rootResult.data : "", remoteNames);
628
+ setSelectedRemote(defaultRemote);
629
+ } else {
630
+ setErrors((prev) => ({ ...prev, remotes: remotesResult.error }));
631
+ }
632
+ setLoading((prev) => ({ ...prev, remotes: false }));
633
+ }, []);
634
+ useEffect3(() => {
635
+ if (!selectedRemote || !currentBranch) return;
636
+ const remote = remotes.find((r) => r.name === selectedRemote);
637
+ if (!remote) return;
638
+ const repo = getRepoFromRemote(remote.url);
639
+ if (!repo) return;
640
+ setCurrentRepoSlug(repo);
641
+ setLoading((prev) => ({ ...prev, prs: true }));
642
+ setPrs([]);
643
+ setSelectedPR(null);
644
+ const fetchPRs = async () => {
645
+ try {
646
+ const result = await listPRsForBranch(currentBranch, repo);
647
+ if (result.success) {
648
+ setPrs(result.data);
649
+ if (result.data.length > 0) {
650
+ setSelectedPR(result.data[0]);
651
+ }
652
+ setErrors((prev) => ({ ...prev, prs: void 0 }));
653
+ } else {
654
+ setErrors((prev) => ({ ...prev, prs: result.error }));
655
+ }
656
+ } catch (err) {
657
+ setErrors((prev) => ({ ...prev, prs: String(err) }));
658
+ } finally {
659
+ setLoading((prev) => ({ ...prev, prs: false }));
660
+ }
661
+ };
662
+ fetchPRs();
663
+ }, [selectedRemote, currentBranch, remotes]);
664
+ useEffect3(() => {
665
+ if (!selectedPR || !currentRepoSlug) {
666
+ setPrDetails(null);
667
+ return;
668
+ }
669
+ setLoading((prev) => ({ ...prev, details: true }));
670
+ const fetchDetails = async () => {
671
+ try {
672
+ const result = await getPRDetails(selectedPR.number, currentRepoSlug);
673
+ if (result.success) {
674
+ setPrDetails(result.data);
675
+ setErrors((prev) => ({ ...prev, details: void 0 }));
676
+ } else {
677
+ setErrors((prev) => ({ ...prev, details: result.error }));
678
+ }
679
+ } catch (err) {
680
+ setErrors((prev) => ({ ...prev, details: String(err) }));
681
+ } finally {
682
+ setLoading((prev) => ({ ...prev, details: false }));
683
+ }
684
+ };
685
+ fetchDetails();
686
+ }, [selectedPR, currentRepoSlug]);
687
+ const handleRemoteSelect = useCallback(
688
+ (remoteName) => {
689
+ setSelectedRemote(remoteName);
690
+ if (repoPath) {
691
+ updateRepoConfig(repoPath, { selectedRemote: remoteName });
692
+ }
693
+ },
694
+ [repoPath]
695
+ );
696
+ const handlePRSelect = useCallback((pr) => {
697
+ setSelectedPR(pr);
698
+ }, []);
699
+ const handleCreatePR = useCallback(() => {
700
+ setShowCreatePR(true);
701
+ setErrors((prev) => ({ ...prev, createPR: void 0 }));
702
+ }, []);
703
+ const handleCreatePRSubmit = useCallback(
704
+ async (title, body) => {
705
+ if (!currentRepoSlug) return;
706
+ setLoading((prev) => ({ ...prev, createPR: true }));
707
+ setErrors((prev) => ({ ...prev, createPR: void 0 }));
708
+ try {
709
+ const result = await createPR(currentRepoSlug, title, body);
710
+ if (result.success) {
711
+ setShowCreatePR(false);
712
+ if (currentBranch) {
713
+ const prsResult = await listPRsForBranch(currentBranch, currentRepoSlug);
714
+ if (prsResult.success) {
715
+ setPrs(prsResult.data);
716
+ const newPR = prsResult.data.find((p) => p.number === result.data.number);
717
+ if (newPR) {
718
+ setSelectedPR(newPR);
719
+ }
720
+ }
721
+ }
722
+ } else {
723
+ setErrors((prev) => ({ ...prev, createPR: result.error }));
724
+ }
725
+ } catch (err) {
726
+ setErrors((prev) => ({ ...prev, createPR: String(err) }));
727
+ } finally {
728
+ setLoading((prev) => ({ ...prev, createPR: false }));
729
+ }
730
+ },
731
+ [currentRepoSlug, currentBranch]
732
+ );
733
+ const handleCreatePRCancel = useCallback(() => {
734
+ setShowCreatePR(false);
735
+ setErrors((prev) => ({ ...prev, createPR: void 0 }));
736
+ }, []);
737
+ useInput4(
738
+ (input) => {
739
+ if (showCreatePR) return;
740
+ if (input === "1") setFocusedBox("remotes");
741
+ if (input === "2") setFocusedBox("prs");
742
+ if (input === "3") setFocusedBox("details");
743
+ },
744
+ { isActive: !showCreatePR }
745
+ );
746
+ if (isRepo === false) {
747
+ return /* @__PURE__ */ jsx5(TitledBox4, { borderStyle: "round", titles: ["Error"], flexGrow: 1, children: /* @__PURE__ */ jsx5(Text5, { color: "red", children: "Current directory is not a git repository" }) });
748
+ }
749
+ if (showCreatePR) {
750
+ return /* @__PURE__ */ jsx5(Box5, { flexDirection: "column", flexGrow: 1, children: /* @__PURE__ */ jsx5(
751
+ CreatePRModal,
752
+ {
753
+ template: prTemplate,
754
+ onSubmit: handleCreatePRSubmit,
755
+ onCancel: handleCreatePRCancel,
756
+ loading: loading.createPR,
757
+ error: errors.createPR
758
+ }
759
+ ) });
760
+ }
761
+ return /* @__PURE__ */ jsxs5(Box5, { flexDirection: "column", flexGrow: 1, children: [
762
+ /* @__PURE__ */ jsx5(
763
+ RemotesBox,
764
+ {
765
+ remotes,
766
+ selectedRemote,
767
+ onSelect: handleRemoteSelect,
768
+ loading: loading.remotes,
769
+ error: errors.remotes,
770
+ isFocused: focusedBox === "remotes"
771
+ }
772
+ ),
773
+ /* @__PURE__ */ jsx5(
774
+ PullRequestsBox,
775
+ {
776
+ prs,
777
+ selectedPR,
778
+ onSelect: handlePRSelect,
779
+ onCreatePR: handleCreatePR,
780
+ loading: loading.prs,
781
+ error: errors.prs,
782
+ branch: currentBranch,
783
+ isFocused: focusedBox === "prs"
784
+ }
785
+ ),
786
+ /* @__PURE__ */ jsx5(
787
+ PRDetailsBox,
788
+ {
789
+ pr: prDetails,
790
+ loading: loading.details,
791
+ error: errors.details,
792
+ isFocused: focusedBox === "details"
793
+ }
794
+ )
795
+ ] });
796
+ }
797
+
798
+ // src/components/ui/Tabs.tsx
799
+ import React, { useState as useState5 } from "react";
800
+ import { Box as Box6, Text as Text6, useInput as useInput5 } from "ink";
801
+ import { jsx as jsx6, jsxs as jsxs6 } from "react/jsx-runtime";
802
+ function TabItem({ children }) {
803
+ return /* @__PURE__ */ jsx6(Box6, { flexGrow: 1, children });
804
+ }
805
+ function Tabs({ children, defaultTab }) {
806
+ const childArray = React.Children.toArray(children);
807
+ const tabNames = childArray.map((child) => child.props.name);
808
+ const [activeTab, setActiveTab] = useState5(defaultTab ?? tabNames[0]);
809
+ useInput5((_input, key) => {
810
+ if (key.tab && activeTab) {
811
+ const currentIndex = tabNames.indexOf(activeTab);
812
+ const newIndex = key.shift ? currentIndex === 0 ? tabNames.length - 1 : currentIndex - 1 : (currentIndex + 1) % tabNames.length;
813
+ setActiveTab(tabNames[newIndex]);
814
+ }
815
+ });
816
+ return /* @__PURE__ */ jsxs6(Box6, { flexDirection: "column", flexGrow: 1, minHeight: 0, children: [
817
+ /* @__PURE__ */ jsx6(Box6, { paddingX: 1, gap: 1, flexShrink: 0, children: tabNames.map((name) => /* @__PURE__ */ jsx6(Text6, { inverse: activeTab === name, bold: activeTab === name, children: `${name} ` }, name)) }),
818
+ /* @__PURE__ */ jsx6(Box6, { flexGrow: 1, marginTop: 1, overflow: "hidden", children: childArray.map((child) => /* @__PURE__ */ jsx6(Box6, { display: child.props.name === activeTab ? "flex" : "none", flexGrow: 1, children: child }, child.props.name)) })
819
+ ] });
820
+ }
821
+
822
+ // src/app.tsx
823
+ import { jsx as jsx7 } from "react/jsx-runtime";
824
+ function App() {
825
+ return /* @__PURE__ */ jsx7(Box7, { flexGrow: 1, flexDirection: "column", overflow: "hidden", children: /* @__PURE__ */ jsx7(Tabs, { children: /* @__PURE__ */ jsx7(TabItem, { name: "GitHub", children: /* @__PURE__ */ jsx7(GitHubView, {}) }) }) });
826
+ }
827
+
828
+ // src/lib/render.tsx
829
+ import { render as inkRender } from "ink";
830
+
831
+ // src/lib/Screen.tsx
832
+ import { Box as Box8, useStdout } from "ink";
833
+ import { useCallback as useCallback2, useEffect as useEffect4, useState as useState6 } from "react";
834
+ import { jsx as jsx8 } from "react/jsx-runtime";
835
+ function Screen({ children }) {
836
+ const { stdout } = useStdout();
837
+ const getSize = useCallback2(
838
+ () => ({ height: stdout.rows, width: stdout.columns }),
839
+ [stdout]
840
+ );
841
+ const [size, setSize] = useState6(getSize);
842
+ useEffect4(() => {
843
+ const onResize = () => setSize(getSize());
844
+ stdout.on("resize", onResize);
845
+ return () => {
846
+ stdout.off("resize", onResize);
847
+ };
848
+ }, [stdout, getSize]);
849
+ return /* @__PURE__ */ jsx8(Box8, { height: size.height, width: size.width, children });
850
+ }
851
+
852
+ // src/lib/render.tsx
853
+ import { jsx as jsx9 } from "react/jsx-runtime";
854
+ var ENTER_ALT_BUFFER = "\x1B[?1049h";
855
+ var EXIT_ALT_BUFFER = "\x1B[?1049l";
856
+ var CLEAR_SCREEN = "\x1B[2J\x1B[H";
857
+ function render(node, options) {
858
+ process.stdout.write(ENTER_ALT_BUFFER + CLEAR_SCREEN);
859
+ const element = /* @__PURE__ */ jsx9(Screen, { children: node });
860
+ const instance = inkRender(element, options);
861
+ setImmediate(() => instance.rerender(element));
862
+ const cleanup = () => process.stdout.write(EXIT_ALT_BUFFER);
863
+ const originalWaitUntilExit = instance.waitUntilExit.bind(instance);
864
+ instance.waitUntilExit = async () => {
865
+ await originalWaitUntilExit();
866
+ cleanup();
867
+ };
868
+ process.on("exit", cleanup);
869
+ const handleSignal = () => {
870
+ cleanup();
871
+ instance.unmount();
872
+ process.exit(0);
873
+ };
874
+ process.on("SIGINT", handleSignal);
875
+ process.on("SIGTERM", handleSignal);
876
+ return instance;
877
+ }
878
+
879
+ // src/cli.tsx
880
+ import { jsx as jsx10 } from "react/jsx-runtime";
881
+ meow(
882
+ `
883
+ Usage
884
+ $ clairo
885
+
886
+ Options
887
+ --name Your name
888
+
889
+ Examples
890
+ $ clairo --name=Jane
891
+ Hello, Jane
892
+ `,
893
+ {
894
+ importMeta: import.meta,
895
+ flags: {
896
+ name: {
897
+ type: "string"
898
+ }
899
+ }
900
+ }
901
+ );
902
+ render(/* @__PURE__ */ jsx10(App, {}));
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "clairo",
3
+ "version": "0.1.0",
4
+ "license": "MIT",
5
+ "bin": "dist/cli.js",
6
+ "type": "module",
7
+ "engines": {
8
+ "node": ">=16"
9
+ },
10
+ "scripts": {
11
+ "build": "tsup",
12
+ "dev": "tsx --watch src/cli.tsx",
13
+ "start": "node dist/cli.js",
14
+ "lint": "prettier --write . && eslint . --fix"
15
+ },
16
+ "files": [
17
+ "dist"
18
+ ],
19
+ "dependencies": {
20
+ "@mishieck/ink-titled-box": "^0.4.2",
21
+ "ink": "^6.6.0",
22
+ "meow": "^11.0.0",
23
+ "react": "^19.2.4"
24
+ },
25
+ "devDependencies": {
26
+ "@trivago/prettier-plugin-sort-imports": "^4.3.0",
27
+ "@types/node": "^22.0.0",
28
+ "@types/react": "^19.2.13",
29
+ "chalk": "^5.2.0",
30
+ "eslint": "^9.0.0",
31
+ "eslint-plugin-react": "^7.32.2",
32
+ "prettier": "^2.8.7",
33
+ "tsup": "^8.5.1",
34
+ "tsx": "^4.21.0",
35
+ "typescript": "^5.0.3",
36
+ "typescript-eslint": "^8.0.0"
37
+ }
38
+ }