clairo 1.0.5 → 1.0.7

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 (2) hide show
  1. package/dist/cli.js +1038 -626
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -4,68 +4,14 @@
4
4
  import meow from "meow";
5
5
 
6
6
  // src/app.tsx
7
- import { useCallback as useCallback4, useState as useState10 } from "react";
7
+ import { useCallback as useCallback9, useMemo as useMemo2, useState as useState16 } from "react";
8
8
  import { Box as Box16, useApp, useInput as useInput13 } from "ink";
9
9
 
10
10
  // src/components/github/GitHubView.tsx
11
- import { exec as exec3 } from "child_process";
12
- import { useCallback, useEffect as useEffect3, useRef as useRef2, useState as useState3 } from "react";
11
+ import { useCallback as useCallback4, useEffect as useEffect6, useRef as useRef3, useState as useState7 } from "react";
13
12
  import { TitledBox as TitledBox3 } from "@mishieck/ink-titled-box";
14
13
  import { Box as Box5, Text as Text5, useInput as useInput4 } from "ink";
15
14
 
16
- // src/lib/config/index.ts
17
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
18
- import { homedir } from "os";
19
- import { dirname, join } from "path";
20
- var CONFIG_PATH = join(homedir(), ".clairo", "config.json");
21
- var DEFAULT_CONFIG = {};
22
- function loadConfig() {
23
- try {
24
- if (!existsSync(CONFIG_PATH)) {
25
- return DEFAULT_CONFIG;
26
- }
27
- const content = readFileSync(CONFIG_PATH, "utf-8");
28
- return JSON.parse(content);
29
- } catch {
30
- return DEFAULT_CONFIG;
31
- }
32
- }
33
- function saveConfig(config) {
34
- const dir = dirname(CONFIG_PATH);
35
- if (!existsSync(dir)) {
36
- mkdirSync(dir, { recursive: true });
37
- }
38
- writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
39
- }
40
-
41
- // src/lib/github/config.ts
42
- function getRepoConfig(repoPath) {
43
- const config = loadConfig();
44
- const repos = config.repositories ?? {};
45
- return repos[repoPath] ?? {};
46
- }
47
- function updateRepoConfig(repoPath, updates) {
48
- const config = loadConfig();
49
- if (!config.repositories) {
50
- config.repositories = {};
51
- }
52
- config.repositories[repoPath] = {
53
- ...config.repositories[repoPath],
54
- ...updates
55
- };
56
- saveConfig(config);
57
- }
58
- function getSelectedRemote(repoPath, availableRemotes) {
59
- const repoConfig = getRepoConfig(repoPath);
60
- if (repoConfig.selectedRemote && availableRemotes.includes(repoConfig.selectedRemote)) {
61
- return repoConfig.selectedRemote;
62
- }
63
- if (availableRemotes.includes("origin")) {
64
- return "origin";
65
- }
66
- return availableRemotes[0] ?? null;
67
- }
68
-
69
15
  // src/lib/github/git.ts
70
16
  import { execSync } from "child_process";
71
17
  function isGitRepo() {
@@ -267,6 +213,13 @@ async function getPRDetails(prNumber, repo) {
267
213
  };
268
214
  }
269
215
  }
216
+ function openPRCreationPage(owner, branch, onComplete) {
217
+ const headFlag = `${owner}:${branch}`;
218
+ exec(`gh pr create --web --head "${headFlag}"`, (error) => {
219
+ process.stdout.emit("resize");
220
+ onComplete == null ? void 0 : onComplete(error);
221
+ });
222
+ }
270
223
 
271
224
  // src/lib/jira/parser.ts
272
225
  var TICKET_KEY_PATTERN = /^[A-Z][A-Z0-9]+-\d+$/;
@@ -285,8 +238,8 @@ function parseTicketKey(input) {
285
238
  }
286
239
  return null;
287
240
  }
288
- function extractTicketKeyFromBranch(branchName) {
289
- const match = branchName.match(/([A-Za-z][A-Za-z0-9]+-\d+)/);
241
+ function extractTicketKey(text) {
242
+ const match = text.match(/([A-Za-z][A-Za-z0-9]+-\d+)/);
290
243
  if (match) {
291
244
  const candidate = match[1].toUpperCase();
292
245
  if (isValidTicketKeyFormat(candidate)) {
@@ -296,6 +249,59 @@ function extractTicketKeyFromBranch(branchName) {
296
249
  return null;
297
250
  }
298
251
 
252
+ // src/lib/config/index.ts
253
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
254
+ import { homedir } from "os";
255
+ import { dirname, join } from "path";
256
+ var CONFIG_PATH = join(homedir(), ".clairo", "config.json");
257
+ var DEFAULT_CONFIG = {};
258
+ function loadConfig() {
259
+ try {
260
+ if (!existsSync(CONFIG_PATH)) {
261
+ return DEFAULT_CONFIG;
262
+ }
263
+ const content = readFileSync(CONFIG_PATH, "utf-8");
264
+ return JSON.parse(content);
265
+ } catch {
266
+ return DEFAULT_CONFIG;
267
+ }
268
+ }
269
+ function saveConfig(config) {
270
+ const dir = dirname(CONFIG_PATH);
271
+ if (!existsSync(dir)) {
272
+ mkdirSync(dir, { recursive: true });
273
+ }
274
+ writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
275
+ }
276
+
277
+ // src/lib/github/config.ts
278
+ function getRepoConfig(repoPath) {
279
+ const config = loadConfig();
280
+ const repos = config.repositories ?? {};
281
+ return repos[repoPath] ?? {};
282
+ }
283
+ function updateRepoConfig(repoPath, updates) {
284
+ const config = loadConfig();
285
+ if (!config.repositories) {
286
+ config.repositories = {};
287
+ }
288
+ config.repositories[repoPath] = {
289
+ ...config.repositories[repoPath],
290
+ ...updates
291
+ };
292
+ saveConfig(config);
293
+ }
294
+ function getSelectedRemote(repoPath, availableRemotes) {
295
+ const repoConfig = getRepoConfig(repoPath);
296
+ if (repoConfig.selectedRemote && availableRemotes.includes(repoConfig.selectedRemote)) {
297
+ return repoConfig.selectedRemote;
298
+ }
299
+ if (availableRemotes.includes("origin")) {
300
+ return "origin";
301
+ }
302
+ return availableRemotes[0] ?? null;
303
+ }
304
+
299
305
  // src/lib/jira/config.ts
300
306
  function isJiraConfigured(repoPath) {
301
307
  const config = getRepoConfig(repoPath);
@@ -620,9 +626,265 @@ ${oldStatus} \u2192 ${newStatus}
620
626
  appendToLog(today, entry);
621
627
  }
622
628
 
629
+ // src/hooks/github/useGitRepo.ts
630
+ import { useCallback, useEffect as useEffect2, useMemo, useState as useState2 } from "react";
631
+
632
+ // src/hooks/useTerminalFocus.ts
633
+ import { useEffect, useState } from "react";
634
+ function useTerminalFocus() {
635
+ const [isFocused, setIsFocused] = useState(null);
636
+ const [focusCount, setFocusCount] = useState(0);
637
+ useEffect(() => {
638
+ process.stdout.write("\x1B[?1004h");
639
+ const handleData = (data) => {
640
+ const str = data.toString();
641
+ if (str.includes("\x1B[I")) {
642
+ setIsFocused(true);
643
+ setFocusCount((c) => c + 1);
644
+ }
645
+ if (str.includes("\x1B[O")) {
646
+ setIsFocused(false);
647
+ }
648
+ };
649
+ process.stdin.on("data", handleData);
650
+ return () => {
651
+ process.stdout.write("\x1B[?1004l");
652
+ process.stdin.off("data", handleData);
653
+ };
654
+ }, []);
655
+ return { isFocused, focusCount };
656
+ }
657
+
658
+ // src/hooks/github/useGitRepo.ts
659
+ function useGitRepo() {
660
+ const [isRepo, setIsRepo] = useState2(null);
661
+ const [repoPath, setRepoPath] = useState2(null);
662
+ const [remotes, setRemotes] = useState2([]);
663
+ const [currentBranch, setCurrentBranch] = useState2(null);
664
+ const [selectedRemote, setSelectedRemote] = useState2(null);
665
+ const [loading, setLoading] = useState2(true);
666
+ const [error, setError] = useState2(void 0);
667
+ const { focusCount } = useTerminalFocus();
668
+ const currentRepoSlug = useMemo(() => {
669
+ if (!selectedRemote) return null;
670
+ const remote = remotes.find((r) => r.name === selectedRemote);
671
+ if (!remote) return null;
672
+ return getRepoFromRemote(remote.url);
673
+ }, [selectedRemote, remotes]);
674
+ useEffect2(() => {
675
+ const gitRepoCheck = isGitRepo();
676
+ setIsRepo(gitRepoCheck);
677
+ if (!gitRepoCheck) {
678
+ setLoading(false);
679
+ setError("Not a git repository");
680
+ return;
681
+ }
682
+ const rootResult = getRepoRoot();
683
+ if (rootResult.success) {
684
+ setRepoPath(rootResult.data);
685
+ }
686
+ const branchResult = getCurrentBranch();
687
+ if (branchResult.success) {
688
+ setCurrentBranch(branchResult.data);
689
+ }
690
+ const remotesResult = listRemotes();
691
+ if (!remotesResult.success) {
692
+ setError(remotesResult.error);
693
+ setLoading(false);
694
+ return;
695
+ }
696
+ setRemotes(remotesResult.data);
697
+ const remoteNames = remotesResult.data.map((r) => r.name);
698
+ const defaultRemote = getSelectedRemote(rootResult.success ? rootResult.data : "", remoteNames);
699
+ setSelectedRemote(defaultRemote);
700
+ setLoading(false);
701
+ }, []);
702
+ useEffect2(() => {
703
+ if (!isRepo || focusCount === 0) return;
704
+ const result = getCurrentBranch();
705
+ if (result.success && result.data !== currentBranch) {
706
+ setCurrentBranch(result.data);
707
+ }
708
+ }, [isRepo, focusCount]);
709
+ const selectRemote = useCallback(
710
+ (remoteName) => {
711
+ setSelectedRemote(remoteName);
712
+ if (repoPath) {
713
+ updateRepoConfig(repoPath, { selectedRemote: remoteName });
714
+ }
715
+ },
716
+ [repoPath]
717
+ );
718
+ const refreshBranch = useCallback(() => {
719
+ const branchResult = getCurrentBranch();
720
+ if (branchResult.success) {
721
+ setCurrentBranch(branchResult.data);
722
+ return branchResult.data;
723
+ }
724
+ return null;
725
+ }, []);
726
+ return {
727
+ isRepo,
728
+ repoPath,
729
+ remotes,
730
+ currentBranch,
731
+ selectedRemote,
732
+ currentRepoSlug,
733
+ selectRemote,
734
+ refreshBranch,
735
+ loading,
736
+ error
737
+ };
738
+ }
739
+
740
+ // src/hooks/github/usePRPolling.ts
741
+ import { useRef, useEffect as useEffect3, useState as useState3, useCallback as useCallback2 } from "react";
742
+ function usePRPolling() {
743
+ const prNumbersBeforeCreate = useRef(/* @__PURE__ */ new Set());
744
+ const pollingIntervalRef = useRef(null);
745
+ const [isPolling, setIsPolling] = useState3(false);
746
+ const stopPolling = useCallback2(() => {
747
+ if (pollingIntervalRef.current) {
748
+ clearInterval(pollingIntervalRef.current);
749
+ pollingIntervalRef.current = null;
750
+ }
751
+ setIsPolling(false);
752
+ }, []);
753
+ const startPolling = useCallback2(
754
+ (options) => {
755
+ const {
756
+ branch,
757
+ repoSlug,
758
+ existingPRNumbers,
759
+ onNewPR,
760
+ onPRsUpdated,
761
+ maxAttempts = 24,
762
+ pollInterval = 5e3
763
+ } = options;
764
+ stopPolling();
765
+ prNumbersBeforeCreate.current = new Set(existingPRNumbers);
766
+ let attempts = 0;
767
+ setIsPolling(true);
768
+ pollingIntervalRef.current = setInterval(async () => {
769
+ attempts++;
770
+ if (attempts > maxAttempts) {
771
+ stopPolling();
772
+ return;
773
+ }
774
+ const result = await listPRsForBranch(branch, repoSlug);
775
+ if (result.success) {
776
+ onPRsUpdated(result.data);
777
+ const newPR = result.data.find(
778
+ (pr) => !prNumbersBeforeCreate.current.has(pr.number)
779
+ );
780
+ if (newPR) {
781
+ stopPolling();
782
+ onNewPR(newPR);
783
+ }
784
+ }
785
+ }, pollInterval);
786
+ },
787
+ [stopPolling]
788
+ );
789
+ useEffect3(() => {
790
+ return () => {
791
+ if (pollingIntervalRef.current) {
792
+ clearInterval(pollingIntervalRef.current);
793
+ }
794
+ };
795
+ }, []);
796
+ return {
797
+ startPolling,
798
+ stopPolling,
799
+ isPolling
800
+ };
801
+ }
802
+
803
+ // src/hooks/github/usePullRequests.ts
804
+ import { useCallback as useCallback3, useState as useState4 } from "react";
805
+ function usePullRequests() {
806
+ const [prs, setPrs] = useState4([]);
807
+ const [selectedPR, setSelectedPR] = useState4(null);
808
+ const [prDetails, setPrDetails] = useState4(null);
809
+ const [loading, setLoading] = useState4({
810
+ prs: false,
811
+ details: false
812
+ });
813
+ const [errors, setErrors] = useState4({});
814
+ const refreshPRs = useCallback3(async (branch, repoSlug) => {
815
+ setLoading((prev) => ({ ...prev, prs: true }));
816
+ setPrs([]);
817
+ setSelectedPR(null);
818
+ setPrDetails(null);
819
+ try {
820
+ const result = await listPRsForBranch(branch, repoSlug);
821
+ if (result.success) {
822
+ setPrs(result.data);
823
+ setErrors((prev) => ({ ...prev, prs: void 0 }));
824
+ return result.data[0] ?? null;
825
+ } else {
826
+ setErrors((prev) => ({ ...prev, prs: result.error }));
827
+ return null;
828
+ }
829
+ } catch (err) {
830
+ setErrors((prev) => ({ ...prev, prs: String(err) }));
831
+ return null;
832
+ } finally {
833
+ setLoading((prev) => ({ ...prev, prs: false }));
834
+ }
835
+ }, []);
836
+ const refreshDetails = useCallback3(async (pr, repoSlug) => {
837
+ setLoading((prev) => ({ ...prev, details: true }));
838
+ try {
839
+ const result = await getPRDetails(pr.number, repoSlug);
840
+ if (result.success) {
841
+ setPrDetails(result.data);
842
+ setErrors((prev) => ({ ...prev, details: void 0 }));
843
+ } else {
844
+ setErrors((prev) => ({ ...prev, details: result.error }));
845
+ }
846
+ } catch (err) {
847
+ setErrors((prev) => ({ ...prev, details: String(err) }));
848
+ } finally {
849
+ setLoading((prev) => ({ ...prev, details: false }));
850
+ }
851
+ }, []);
852
+ const fetchPRsAndDetails = useCallback3(async (branch, repoSlug) => {
853
+ const firstPR = await refreshPRs(branch, repoSlug);
854
+ if (firstPR) {
855
+ setSelectedPR(firstPR);
856
+ refreshDetails(firstPR, repoSlug);
857
+ }
858
+ }, [refreshPRs, refreshDetails]);
859
+ const selectPR = useCallback3((pr, repoSlug) => {
860
+ setSelectedPR(pr);
861
+ if (repoSlug) {
862
+ refreshDetails(pr, repoSlug);
863
+ }
864
+ }, [refreshDetails]);
865
+ const setError = useCallback3((key, message) => {
866
+ setErrors((prev) => ({ ...prev, [key]: message }));
867
+ }, []);
868
+ return {
869
+ prs,
870
+ selectedPR,
871
+ prDetails,
872
+ refreshPRs,
873
+ refreshDetails,
874
+ fetchPRsAndDetails,
875
+ selectPR,
876
+ loading,
877
+ errors,
878
+ setError,
879
+ // Expose setters for cases where external code needs to update state directly
880
+ setPrs,
881
+ setSelectedPR
882
+ };
883
+ }
884
+
623
885
  // src/components/github/PRDetailsBox.tsx
624
886
  import open from "open";
625
- import { useRef } from "react";
887
+ import { useRef as useRef2 } from "react";
626
888
  import { Box as Box2, Text as Text2, useInput, useStdout } from "ink";
627
889
  import { ScrollView } from "ink-scroll-view";
628
890
 
@@ -768,7 +1030,7 @@ function getCheckSortOrder(check) {
768
1030
  }
769
1031
  function PRDetailsBox({ pr, loading, error, isFocused }) {
770
1032
  var _a, _b, _c, _d, _e, _f, _g;
771
- const scrollRef = useRef(null);
1033
+ const scrollRef = useRef2(null);
772
1034
  const title = "[3] PR Details";
773
1035
  const borderColor = isFocused ? "yellow" : void 0;
774
1036
  const displayTitle = pr ? `${title} - #${pr.number}` : title;
@@ -892,7 +1154,7 @@ function PRDetailsBox({ pr, loading, error, isFocused }) {
892
1154
  }
893
1155
 
894
1156
  // src/components/github/PullRequestsBox.tsx
895
- import { useEffect, useState } from "react";
1157
+ import { useEffect as useEffect4, useState as useState5 } from "react";
896
1158
  import { TitledBox } from "@mishieck/ink-titled-box";
897
1159
  import { Box as Box3, Text as Text3, useInput as useInput2 } from "ink";
898
1160
 
@@ -930,9 +1192,9 @@ function PullRequestsBox({
930
1192
  repoSlug,
931
1193
  isFocused
932
1194
  }) {
933
- const [highlightedIndex, setHighlightedIndex] = useState(0);
1195
+ const [highlightedIndex, setHighlightedIndex] = useState5(0);
934
1196
  const totalItems = prs.length + 1;
935
- useEffect(() => {
1197
+ useEffect4(() => {
936
1198
  const idx = prs.findIndex((p) => p.number === (selectedPR == null ? void 0 : selectedPR.number));
937
1199
  if (idx >= 0) setHighlightedIndex(idx);
938
1200
  }, [selectedPR, prs]);
@@ -997,13 +1259,13 @@ function PullRequestsBox({
997
1259
  }
998
1260
 
999
1261
  // src/components/github/RemotesBox.tsx
1000
- import { useEffect as useEffect2, useState as useState2 } from "react";
1262
+ import { useEffect as useEffect5, useState as useState6 } from "react";
1001
1263
  import { TitledBox as TitledBox2 } from "@mishieck/ink-titled-box";
1002
1264
  import { Box as Box4, Text as Text4, useInput as useInput3 } from "ink";
1003
1265
  import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
1004
1266
  function RemotesBox({ remotes, selectedRemote, onSelect, loading, error, isFocused }) {
1005
- const [highlightedIndex, setHighlightedIndex] = useState2(0);
1006
- useEffect2(() => {
1267
+ const [highlightedIndex, setHighlightedIndex] = useState6(0);
1268
+ useEffect5(() => {
1007
1269
  const idx = remotes.findIndex((r) => r.name === selectedRemote);
1008
1270
  if (idx >= 0) setHighlightedIndex(idx);
1009
1271
  }, [selectedRemote, remotes]);
@@ -1052,209 +1314,98 @@ function RemotesBox({ remotes, selectedRemote, onSelect, loading, error, isFocus
1052
1314
 
1053
1315
  // src/components/github/GitHubView.tsx
1054
1316
  import { jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
1055
- function GitHubView({ isFocused, onKeybindingsChange, onLogUpdated }) {
1056
- const [isRepo, setIsRepo] = useState3(null);
1057
- const [repoPath, setRepoPath] = useState3(null);
1058
- const [remotes, setRemotes] = useState3([]);
1059
- const [currentBranch, setCurrentBranch] = useState3(null);
1060
- const [currentRepoSlug, setCurrentRepoSlug] = useState3(null);
1061
- const [selectedRemote, setSelectedRemote] = useState3(null);
1062
- const [selectedPR, setSelectedPR] = useState3(null);
1063
- const [prs, setPrs] = useState3([]);
1064
- const [prDetails, setPrDetails] = useState3(null);
1065
- const [loading, setLoading] = useState3({
1066
- remotes: true,
1067
- prs: false,
1068
- details: false
1069
- });
1070
- const [errors, setErrors] = useState3({});
1071
- const [focusedBox, setFocusedBox] = useState3("remotes");
1072
- useEffect3(() => {
1073
- if (!isFocused) {
1074
- onKeybindingsChange == null ? void 0 : onKeybindingsChange([]);
1075
- return;
1076
- }
1077
- const bindings = [];
1078
- if (focusedBox === "remotes") {
1079
- bindings.push({ key: "Space", label: "Select Remote" });
1080
- } else if (focusedBox === "prs") {
1081
- bindings.push({ key: "Space", label: "Select" });
1082
- bindings.push({ key: "n", label: "New PR", color: "green" });
1083
- bindings.push({ key: "r", label: "Refresh" });
1084
- bindings.push({ key: "o", label: "Open", color: "green" });
1085
- bindings.push({ key: "y", label: "Copy Link" });
1086
- } else if (focusedBox === "details") {
1087
- bindings.push({ key: "r", label: "Refresh" });
1088
- bindings.push({ key: "o", label: "Open", color: "green" });
1317
+ function GitHubView({ isFocused, onFocusedBoxChange, onLogUpdated }) {
1318
+ const repo = useGitRepo();
1319
+ const pullRequests = usePullRequests();
1320
+ const polling = usePRPolling();
1321
+ const [focusedBox, setFocusedBox] = useState7("remotes");
1322
+ const lastFetchedRef = useRef3(null);
1323
+ useEffect6(() => {
1324
+ if (repo.loading || !repo.currentBranch || !repo.currentRepoSlug) return;
1325
+ const current = { branch: repo.currentBranch, repoSlug: repo.currentRepoSlug };
1326
+ const last = lastFetchedRef.current;
1327
+ if (last && last.branch === current.branch && last.repoSlug === current.repoSlug) return;
1328
+ lastFetchedRef.current = current;
1329
+ pullRequests.fetchPRsAndDetails(repo.currentBranch, repo.currentRepoSlug);
1330
+ }, [repo.loading, repo.currentBranch, repo.currentRepoSlug, pullRequests.fetchPRsAndDetails]);
1331
+ useEffect6(() => {
1332
+ if (isFocused) {
1333
+ repo.refreshBranch();
1089
1334
  }
1090
- onKeybindingsChange == null ? void 0 : onKeybindingsChange(bindings);
1091
- }, [isFocused, focusedBox, onKeybindingsChange]);
1092
- useEffect3(() => {
1093
- const gitRepoCheck = isGitRepo();
1094
- setIsRepo(gitRepoCheck);
1095
- if (!gitRepoCheck) {
1096
- setLoading((prev) => ({ ...prev, remotes: false }));
1097
- setErrors((prev) => ({ ...prev, remotes: "Not a git repository" }));
1335
+ }, [isFocused, repo.refreshBranch]);
1336
+ useEffect6(() => {
1337
+ onFocusedBoxChange == null ? void 0 : onFocusedBoxChange(focusedBox);
1338
+ }, [focusedBox, onFocusedBoxChange]);
1339
+ const handleRemoteSelect = useCallback4(
1340
+ (remoteName) => {
1341
+ repo.selectRemote(remoteName);
1342
+ const remote = repo.remotes.find((r) => r.name === remoteName);
1343
+ if (!remote || !repo.currentBranch) return;
1344
+ const repoSlug = getRepoFromRemote(remote.url);
1345
+ if (!repoSlug) return;
1346
+ lastFetchedRef.current = { branch: repo.currentBranch, repoSlug };
1347
+ pullRequests.fetchPRsAndDetails(repo.currentBranch, repoSlug);
1348
+ },
1349
+ [repo.selectRemote, repo.remotes, repo.currentBranch, pullRequests.fetchPRsAndDetails]
1350
+ );
1351
+ const handlePRSelect = useCallback4(
1352
+ (pr) => {
1353
+ pullRequests.selectPR(pr, repo.currentRepoSlug);
1354
+ },
1355
+ [pullRequests.selectPR, repo.currentRepoSlug]
1356
+ );
1357
+ const createPRContext = useRef3({ repo, pullRequests, onLogUpdated });
1358
+ createPRContext.current = { repo, pullRequests, onLogUpdated };
1359
+ const handleCreatePR = useCallback4(() => {
1360
+ const { repo: repo2, pullRequests: pullRequests2 } = createPRContext.current;
1361
+ if (!repo2.currentBranch) {
1362
+ pullRequests2.setError("prs", "No branch detected");
1098
1363
  return;
1099
1364
  }
1100
- const rootResult = getRepoRoot();
1101
- if (rootResult.success) {
1102
- setRepoPath(rootResult.data);
1365
+ const remoteResult = findRemoteWithBranch(repo2.currentBranch);
1366
+ if (!remoteResult.success) {
1367
+ pullRequests2.setError("prs", "Push your branch to a remote first");
1368
+ return;
1103
1369
  }
1104
- const branchResult = getCurrentBranch();
1105
- if (branchResult.success) {
1106
- setCurrentBranch(branchResult.data);
1107
- }
1108
- const remotesResult = listRemotes();
1109
- if (remotesResult.success) {
1110
- setRemotes(remotesResult.data);
1111
- const remoteNames = remotesResult.data.map((r) => r.name);
1112
- const defaultRemote = getSelectedRemote(rootResult.success ? rootResult.data : "", remoteNames);
1113
- setSelectedRemote(defaultRemote);
1114
- } else {
1115
- setErrors((prev) => ({ ...prev, remotes: remotesResult.error }));
1116
- }
1117
- setLoading((prev) => ({ ...prev, remotes: false }));
1118
- }, []);
1119
- const refreshPRs = useCallback(async () => {
1120
- if (!currentBranch || !currentRepoSlug) return;
1121
- setLoading((prev) => ({ ...prev, prs: true }));
1122
- try {
1123
- const result = await listPRsForBranch(currentBranch, currentRepoSlug);
1124
- if (result.success) {
1125
- setPrs(result.data);
1126
- if (result.data.length > 0) {
1127
- setSelectedPR((prev) => prev ?? result.data[0]);
1128
- }
1129
- setErrors((prev) => ({ ...prev, prs: void 0 }));
1130
- } else {
1131
- setErrors((prev) => ({ ...prev, prs: result.error }));
1132
- }
1133
- } catch (err) {
1134
- setErrors((prev) => ({ ...prev, prs: String(err) }));
1135
- } finally {
1136
- setLoading((prev) => ({ ...prev, prs: false }));
1137
- }
1138
- }, [currentBranch, currentRepoSlug]);
1139
- const refreshDetails = useCallback(async () => {
1140
- if (!selectedPR || !currentRepoSlug) return;
1141
- setLoading((prev) => ({ ...prev, details: true }));
1142
- try {
1143
- const result = await getPRDetails(selectedPR.number, currentRepoSlug);
1144
- if (result.success) {
1145
- setPrDetails(result.data);
1146
- setErrors((prev) => ({ ...prev, details: void 0 }));
1147
- } else {
1148
- setErrors((prev) => ({ ...prev, details: result.error }));
1149
- }
1150
- } catch (err) {
1151
- setErrors((prev) => ({ ...prev, details: String(err) }));
1152
- } finally {
1153
- setLoading((prev) => ({ ...prev, details: false }));
1154
- }
1155
- }, [selectedPR, currentRepoSlug]);
1156
- useEffect3(() => {
1157
- if (!selectedRemote || !currentBranch) return;
1158
- const remote = remotes.find((r) => r.name === selectedRemote);
1159
- if (!remote) return;
1160
- const repo = getRepoFromRemote(remote.url);
1161
- if (!repo) return;
1162
- setCurrentRepoSlug(repo);
1163
- setPrs([]);
1164
- setSelectedPR(null);
1165
- }, [selectedRemote, currentBranch, remotes]);
1166
- useEffect3(() => {
1167
- if (currentRepoSlug && currentBranch) {
1168
- refreshPRs();
1169
- }
1170
- }, [currentRepoSlug, currentBranch, refreshPRs]);
1171
- useEffect3(() => {
1172
- if (!selectedPR || !currentRepoSlug) {
1173
- setPrDetails(null);
1174
- return;
1175
- }
1176
- refreshDetails();
1177
- }, [selectedPR, currentRepoSlug, refreshDetails]);
1178
- const handleRemoteSelect = useCallback(
1179
- (remoteName) => {
1180
- setSelectedRemote(remoteName);
1181
- if (repoPath) {
1182
- updateRepoConfig(repoPath, { selectedRemote: remoteName });
1183
- }
1184
- },
1185
- [repoPath]
1186
- );
1187
- const handlePRSelect = useCallback((pr) => {
1188
- setSelectedPR(pr);
1189
- }, []);
1190
- const prNumbersBeforeCreate = useRef2(/* @__PURE__ */ new Set());
1191
- const pollingIntervalRef = useRef2(null);
1192
- const handleCreatePR = useCallback(() => {
1193
- if (!currentBranch) {
1194
- setErrors((prev) => ({ ...prev, prs: "No branch detected" }));
1195
- return;
1196
- }
1197
- const remoteResult = findRemoteWithBranch(currentBranch);
1198
- if (!remoteResult.success) {
1199
- setErrors((prev) => ({ ...prev, prs: "Push your branch to a remote first" }));
1200
- return;
1201
- }
1202
- prNumbersBeforeCreate.current = new Set(prs.map((pr) => pr.number));
1203
- const headFlag = `${remoteResult.data.owner}:${currentBranch}`;
1204
- exec3(`gh pr create --web --head "${headFlag}"`, (error) => {
1205
- process.stdout.emit("resize");
1370
+ openPRCreationPage(remoteResult.data.owner, repo2.currentBranch, (error) => {
1206
1371
  if (error) {
1207
- setErrors((prev) => ({ ...prev, prs: `Failed to create PR: ${error.message}` }));
1372
+ pullRequests2.setError("prs", `Failed to create PR: ${error.message}`);
1208
1373
  }
1209
1374
  });
1210
- if (!currentRepoSlug) return;
1211
- let attempts = 0;
1212
- const maxAttempts = 24;
1213
- const pollInterval = 5e3;
1214
- if (pollingIntervalRef.current) {
1215
- clearInterval(pollingIntervalRef.current);
1216
- }
1217
- pollingIntervalRef.current = setInterval(async () => {
1218
- attempts++;
1219
- if (attempts > maxAttempts) {
1220
- if (pollingIntervalRef.current) {
1221
- clearInterval(pollingIntervalRef.current);
1222
- pollingIntervalRef.current = null;
1223
- }
1224
- return;
1225
- }
1226
- const result = await listPRsForBranch(currentBranch, currentRepoSlug);
1227
- if (result.success) {
1228
- setPrs(result.data);
1229
- const newPR = result.data.find((pr) => !prNumbersBeforeCreate.current.has(pr.number));
1230
- if (newPR) {
1231
- if (pollingIntervalRef.current) {
1232
- clearInterval(pollingIntervalRef.current);
1233
- pollingIntervalRef.current = null;
1234
- }
1235
- const tickets = repoPath && currentBranch ? getLinkedTickets(repoPath, currentBranch).map((t) => t.key) : [];
1236
- logPRCreated(newPR.number, newPR.title, tickets);
1237
- onLogUpdated == null ? void 0 : onLogUpdated();
1238
- setSelectedPR(newPR);
1375
+ if (!repo2.currentRepoSlug) return;
1376
+ polling.startPolling({
1377
+ branch: repo2.currentBranch,
1378
+ repoSlug: repo2.currentRepoSlug,
1379
+ existingPRNumbers: pullRequests2.prs.map((pr) => pr.number),
1380
+ onPRsUpdated: (prs) => {
1381
+ pullRequests2.setPrs(prs);
1382
+ },
1383
+ onNewPR: (newPR) => {
1384
+ var _a;
1385
+ const ctx = createPRContext.current;
1386
+ const tickets = ctx.repo.repoPath && ctx.repo.currentBranch ? getLinkedTickets(ctx.repo.repoPath, ctx.repo.currentBranch).map((t) => t.key) : [];
1387
+ logPRCreated(newPR.number, newPR.title, tickets);
1388
+ (_a = ctx.onLogUpdated) == null ? void 0 : _a.call(ctx);
1389
+ ctx.pullRequests.setSelectedPR(newPR);
1390
+ if (ctx.repo.currentRepoSlug) {
1391
+ ctx.pullRequests.refreshDetails(newPR, ctx.repo.currentRepoSlug);
1239
1392
  }
1240
1393
  }
1241
- }, pollInterval);
1242
- }, [prs, currentBranch, currentRepoSlug, repoPath, onLogUpdated]);
1243
- useEffect3(() => {
1244
- return () => {
1245
- if (pollingIntervalRef.current) {
1246
- clearInterval(pollingIntervalRef.current);
1247
- }
1248
- };
1249
- }, []);
1394
+ });
1395
+ }, [polling.startPolling]);
1250
1396
  useInput4(
1251
1397
  (input) => {
1252
1398
  if (input === "1") setFocusedBox("remotes");
1253
1399
  if (input === "2") setFocusedBox("prs");
1254
1400
  if (input === "3") setFocusedBox("details");
1255
1401
  if (input === "r") {
1256
- if (focusedBox === "prs") refreshPRs();
1257
- if (focusedBox === "details") refreshDetails();
1402
+ const freshBranch = repo.refreshBranch() ?? repo.currentBranch;
1403
+ if (focusedBox === "prs" && freshBranch && repo.currentRepoSlug) {
1404
+ pullRequests.fetchPRsAndDetails(freshBranch, repo.currentRepoSlug);
1405
+ }
1406
+ if (focusedBox === "details" && pullRequests.selectedPR && repo.currentRepoSlug) {
1407
+ pullRequests.refreshDetails(pullRequests.selectedPR, repo.currentRepoSlug);
1408
+ }
1258
1409
  }
1259
1410
  if (input === "n" && focusedBox === "prs") {
1260
1411
  handleCreatePR();
@@ -1262,41 +1413,41 @@ function GitHubView({ isFocused, onKeybindingsChange, onLogUpdated }) {
1262
1413
  },
1263
1414
  { isActive: isFocused }
1264
1415
  );
1265
- if (isRepo === false) {
1416
+ if (repo.isRepo === false) {
1266
1417
  return /* @__PURE__ */ jsx5(TitledBox3, { borderStyle: "round", titles: ["Error"], flexGrow: 1, children: /* @__PURE__ */ jsx5(Text5, { color: "red", children: "Current directory is not a git repository" }) });
1267
1418
  }
1268
1419
  return /* @__PURE__ */ jsxs5(Box5, { flexDirection: "column", flexGrow: 1, children: [
1269
1420
  /* @__PURE__ */ jsx5(
1270
1421
  RemotesBox,
1271
1422
  {
1272
- remotes,
1273
- selectedRemote,
1423
+ remotes: repo.remotes,
1424
+ selectedRemote: repo.selectedRemote,
1274
1425
  onSelect: handleRemoteSelect,
1275
- loading: loading.remotes,
1276
- error: errors.remotes,
1426
+ loading: repo.loading,
1427
+ error: repo.error,
1277
1428
  isFocused: isFocused && focusedBox === "remotes"
1278
1429
  }
1279
1430
  ),
1280
1431
  /* @__PURE__ */ jsx5(
1281
1432
  PullRequestsBox,
1282
1433
  {
1283
- prs,
1284
- selectedPR,
1434
+ prs: pullRequests.prs,
1435
+ selectedPR: pullRequests.selectedPR,
1285
1436
  onSelect: handlePRSelect,
1286
1437
  onCreatePR: handleCreatePR,
1287
- loading: loading.prs,
1288
- error: errors.prs,
1289
- branch: currentBranch,
1290
- repoSlug: currentRepoSlug,
1438
+ loading: pullRequests.loading.prs,
1439
+ error: pullRequests.errors.prs,
1440
+ branch: repo.currentBranch,
1441
+ repoSlug: repo.currentRepoSlug,
1291
1442
  isFocused: isFocused && focusedBox === "prs"
1292
1443
  }
1293
1444
  ),
1294
1445
  /* @__PURE__ */ jsx5(
1295
1446
  PRDetailsBox,
1296
1447
  {
1297
- pr: prDetails,
1298
- loading: loading.details,
1299
- error: errors.details,
1448
+ pr: pullRequests.prDetails,
1449
+ loading: pullRequests.loading.details,
1450
+ error: pullRequests.errors.details,
1300
1451
  isFocused: isFocused && focusedBox === "details"
1301
1452
  }
1302
1453
  )
@@ -1304,22 +1455,291 @@ function GitHubView({ isFocused, onKeybindingsChange, onLogUpdated }) {
1304
1455
  }
1305
1456
 
1306
1457
  // src/components/jira/JiraView.tsx
1307
- import { useCallback as useCallback2, useEffect as useEffect5, useState as useState7 } from "react";
1458
+ import { useEffect as useEffect9, useRef as useRef5 } from "react";
1308
1459
  import open2 from "open";
1309
1460
  import { TitledBox as TitledBox4 } from "@mishieck/ink-titled-box";
1310
1461
  import { Box as Box11, Text as Text11, useInput as useInput9 } from "ink";
1311
1462
 
1463
+ // src/hooks/jira/useJiraTickets.ts
1464
+ import { useCallback as useCallback5, useState as useState8 } from "react";
1465
+ function useJiraTickets() {
1466
+ const [jiraState, setJiraState] = useState8("not_configured");
1467
+ const [tickets, setTickets] = useState8([]);
1468
+ const [loading, setLoading] = useState8({ configure: false, link: false });
1469
+ const [errors, setErrors] = useState8({});
1470
+ const initializeJiraState = useCallback5(
1471
+ async (repoPath, currentBranch, repoSlug) => {
1472
+ if (!isJiraConfigured(repoPath)) {
1473
+ setJiraState("not_configured");
1474
+ setTickets([]);
1475
+ return;
1476
+ }
1477
+ const linkedTickets = getLinkedTickets(repoPath, currentBranch);
1478
+ if (linkedTickets.length > 0) {
1479
+ setTickets(linkedTickets);
1480
+ setJiraState("has_tickets");
1481
+ return;
1482
+ }
1483
+ let ticketKey = extractTicketKey(currentBranch);
1484
+ if (!ticketKey && repoSlug) {
1485
+ const prResult = await listPRsForBranch(currentBranch, repoSlug);
1486
+ if (prResult.success && prResult.data.length > 0) {
1487
+ ticketKey = extractTicketKey(prResult.data[0].title);
1488
+ }
1489
+ }
1490
+ if (!ticketKey) {
1491
+ setTickets([]);
1492
+ setJiraState("no_tickets");
1493
+ return;
1494
+ }
1495
+ const siteUrl = getJiraSiteUrl(repoPath);
1496
+ const creds = getJiraCredentials(repoPath);
1497
+ if (!siteUrl || !creds.email || !creds.apiToken) {
1498
+ setTickets([]);
1499
+ setJiraState("no_tickets");
1500
+ return;
1501
+ }
1502
+ const auth = { siteUrl, email: creds.email, apiToken: creds.apiToken };
1503
+ const result = await getIssue(auth, ticketKey);
1504
+ if (result.success) {
1505
+ const linkedTicket = {
1506
+ key: result.data.key,
1507
+ summary: result.data.fields.summary,
1508
+ status: result.data.fields.status.name,
1509
+ linkedAt: (/* @__PURE__ */ new Date()).toISOString()
1510
+ };
1511
+ addLinkedTicket(repoPath, currentBranch, linkedTicket);
1512
+ setTickets([linkedTicket]);
1513
+ setJiraState("has_tickets");
1514
+ } else {
1515
+ setTickets([]);
1516
+ setJiraState("no_tickets");
1517
+ }
1518
+ },
1519
+ []
1520
+ );
1521
+ const refreshTickets = useCallback5((repoPath, currentBranch) => {
1522
+ const linkedTickets = getLinkedTickets(repoPath, currentBranch);
1523
+ setTickets(linkedTickets);
1524
+ setJiraState(linkedTickets.length > 0 ? "has_tickets" : "no_tickets");
1525
+ }, []);
1526
+ const configureJira = useCallback5(
1527
+ async (repoPath, siteUrl, email, apiToken) => {
1528
+ setLoading((prev) => ({ ...prev, configure: true }));
1529
+ setErrors((prev) => ({ ...prev, configure: void 0 }));
1530
+ const auth = { siteUrl, email, apiToken };
1531
+ const result = await validateCredentials(auth);
1532
+ if (!result.success) {
1533
+ setErrors((prev) => ({ ...prev, configure: result.error }));
1534
+ setLoading((prev) => ({ ...prev, configure: false }));
1535
+ return false;
1536
+ }
1537
+ setJiraSiteUrl(repoPath, siteUrl);
1538
+ setJiraCredentials(repoPath, email, apiToken);
1539
+ setJiraState("no_tickets");
1540
+ setLoading((prev) => ({ ...prev, configure: false }));
1541
+ return true;
1542
+ },
1543
+ []
1544
+ );
1545
+ const linkTicket = useCallback5(
1546
+ async (repoPath, currentBranch, ticketInput) => {
1547
+ setLoading((prev) => ({ ...prev, link: true }));
1548
+ setErrors((prev) => ({ ...prev, link: void 0 }));
1549
+ const ticketKey = parseTicketKey(ticketInput);
1550
+ if (!ticketKey) {
1551
+ setErrors((prev) => ({ ...prev, link: "Invalid ticket format. Use PROJ-123 or a Jira URL." }));
1552
+ setLoading((prev) => ({ ...prev, link: false }));
1553
+ return false;
1554
+ }
1555
+ const siteUrl = getJiraSiteUrl(repoPath);
1556
+ const creds = getJiraCredentials(repoPath);
1557
+ if (!siteUrl || !creds.email || !creds.apiToken) {
1558
+ setErrors((prev) => ({ ...prev, link: "Jira not configured" }));
1559
+ setLoading((prev) => ({ ...prev, link: false }));
1560
+ return false;
1561
+ }
1562
+ const auth = { siteUrl, email: creds.email, apiToken: creds.apiToken };
1563
+ const result = await getIssue(auth, ticketKey);
1564
+ if (!result.success) {
1565
+ setErrors((prev) => ({ ...prev, link: result.error }));
1566
+ setLoading((prev) => ({ ...prev, link: false }));
1567
+ return false;
1568
+ }
1569
+ const linkedTicket = {
1570
+ key: result.data.key,
1571
+ summary: result.data.fields.summary,
1572
+ status: result.data.fields.status.name,
1573
+ linkedAt: (/* @__PURE__ */ new Date()).toISOString()
1574
+ };
1575
+ addLinkedTicket(repoPath, currentBranch, linkedTicket);
1576
+ const newTickets = getLinkedTickets(repoPath, currentBranch);
1577
+ setTickets(newTickets);
1578
+ setJiraState("has_tickets");
1579
+ setLoading((prev) => ({ ...prev, link: false }));
1580
+ return true;
1581
+ },
1582
+ []
1583
+ );
1584
+ const unlinkTicket = useCallback5((repoPath, currentBranch, ticketKey) => {
1585
+ removeLinkedTicket(repoPath, currentBranch, ticketKey);
1586
+ }, []);
1587
+ const clearError = useCallback5((key) => {
1588
+ setErrors((prev) => ({ ...prev, [key]: void 0 }));
1589
+ }, []);
1590
+ return {
1591
+ jiraState,
1592
+ tickets,
1593
+ loading,
1594
+ errors,
1595
+ initializeJiraState,
1596
+ refreshTickets,
1597
+ configureJira,
1598
+ linkTicket,
1599
+ unlinkTicket,
1600
+ clearError
1601
+ };
1602
+ }
1603
+
1604
+ // src/hooks/logs/useLogs.ts
1605
+ import { useCallback as useCallback6, useEffect as useEffect7, useRef as useRef4, useState as useState9 } from "react";
1606
+ function useLogs() {
1607
+ const [logFiles, setLogFiles] = useState9([]);
1608
+ const [selectedDate, setSelectedDate] = useState9(null);
1609
+ const [logContent, setLogContent] = useState9(null);
1610
+ const [highlightedIndex, setHighlightedIndex] = useState9(0);
1611
+ const initializedRef = useRef4(false);
1612
+ const loadLogContent = useCallback6((date) => {
1613
+ if (!date) {
1614
+ setLogContent(null);
1615
+ return null;
1616
+ }
1617
+ const content = readLog(date);
1618
+ setLogContent(content);
1619
+ return content;
1620
+ }, []);
1621
+ const refreshLogFiles = useCallback6(() => {
1622
+ const files = listLogFiles();
1623
+ setLogFiles(files);
1624
+ return files;
1625
+ }, []);
1626
+ const initialize = useCallback6(() => {
1627
+ const files = listLogFiles();
1628
+ setLogFiles(files);
1629
+ if (files.length === 0) return;
1630
+ const today = getTodayDate();
1631
+ const todayFile = files.find((f) => f.date === today);
1632
+ if (todayFile) {
1633
+ setSelectedDate(todayFile.date);
1634
+ const idx = files.findIndex((f) => f.date === today);
1635
+ setHighlightedIndex(idx >= 0 ? idx : 0);
1636
+ loadLogContent(todayFile.date);
1637
+ } else {
1638
+ setSelectedDate(files[0].date);
1639
+ setHighlightedIndex(0);
1640
+ loadLogContent(files[0].date);
1641
+ }
1642
+ }, [loadLogContent]);
1643
+ useEffect7(() => {
1644
+ if (initializedRef.current) return;
1645
+ initializedRef.current = true;
1646
+ initialize();
1647
+ }, [initialize]);
1648
+ const selectDate = useCallback6((date) => {
1649
+ setSelectedDate(date);
1650
+ loadLogContent(date);
1651
+ }, [loadLogContent]);
1652
+ const refresh = useCallback6(() => {
1653
+ refreshLogFiles();
1654
+ if (selectedDate) {
1655
+ loadLogContent(selectedDate);
1656
+ }
1657
+ }, [refreshLogFiles, selectedDate, loadLogContent]);
1658
+ const handleExternalLogUpdate = useCallback6(() => {
1659
+ const files = listLogFiles();
1660
+ setLogFiles(files);
1661
+ const today = getTodayDate();
1662
+ if (selectedDate === today) {
1663
+ loadLogContent(today);
1664
+ } else if (!selectedDate && files.length > 0) {
1665
+ const todayFile = files.find((f) => f.date === today);
1666
+ if (todayFile) {
1667
+ setSelectedDate(today);
1668
+ const idx = files.findIndex((f) => f.date === today);
1669
+ setHighlightedIndex(idx >= 0 ? idx : 0);
1670
+ loadLogContent(today);
1671
+ }
1672
+ }
1673
+ }, [selectedDate, loadLogContent]);
1674
+ const handleLogCreated = useCallback6(() => {
1675
+ const files = listLogFiles();
1676
+ setLogFiles(files);
1677
+ const today = getTodayDate();
1678
+ setSelectedDate(today);
1679
+ const idx = files.findIndex((f) => f.date === today);
1680
+ setHighlightedIndex(idx >= 0 ? idx : 0);
1681
+ loadLogContent(today);
1682
+ }, [loadLogContent]);
1683
+ return {
1684
+ logFiles,
1685
+ selectedDate,
1686
+ logContent,
1687
+ highlightedIndex,
1688
+ setHighlightedIndex,
1689
+ selectDate,
1690
+ refresh,
1691
+ handleExternalLogUpdate,
1692
+ handleLogCreated
1693
+ };
1694
+ }
1695
+
1696
+ // src/hooks/useModal.ts
1697
+ import { useCallback as useCallback7, useState as useState10 } from "react";
1698
+ function useModal() {
1699
+ const [modalType, setModalType] = useState10("none");
1700
+ const open3 = useCallback7((type) => setModalType(type), []);
1701
+ const close = useCallback7(() => setModalType("none"), []);
1702
+ const isOpen = modalType !== "none";
1703
+ return {
1704
+ type: modalType,
1705
+ isOpen,
1706
+ open: open3,
1707
+ close
1708
+ };
1709
+ }
1710
+
1711
+ // src/hooks/useListNavigation.ts
1712
+ import { useCallback as useCallback8, useState as useState11 } from "react";
1713
+ function useListNavigation(length) {
1714
+ const [index, setIndex] = useState11(0);
1715
+ const prev = useCallback8(() => {
1716
+ setIndex((i) => Math.max(0, i - 1));
1717
+ }, []);
1718
+ const next = useCallback8(() => {
1719
+ setIndex((i) => Math.min(length - 1, i + 1));
1720
+ }, [length]);
1721
+ const clampedIndex = Math.min(index, Math.max(0, length - 1));
1722
+ const reset = useCallback8(() => setIndex(0), []);
1723
+ return {
1724
+ index: length === 0 ? 0 : clampedIndex,
1725
+ prev,
1726
+ next,
1727
+ reset,
1728
+ setIndex
1729
+ };
1730
+ }
1731
+
1312
1732
  // src/components/jira/ChangeStatusModal.tsx
1313
- import { useEffect as useEffect4, useState as useState4 } from "react";
1733
+ import { useEffect as useEffect8, useState as useState12 } from "react";
1314
1734
  import { Box as Box6, Text as Text6, useInput as useInput5 } from "ink";
1315
1735
  import SelectInput from "ink-select-input";
1316
1736
  import { jsx as jsx6, jsxs as jsxs6 } from "react/jsx-runtime";
1317
1737
  function ChangeStatusModal({ repoPath, ticketKey, currentStatus, onComplete, onCancel }) {
1318
- const [transitions, setTransitions] = useState4([]);
1319
- const [loading, setLoading] = useState4(true);
1320
- const [applying, setApplying] = useState4(false);
1321
- const [error, setError] = useState4(null);
1322
- useEffect4(() => {
1738
+ const [transitions, setTransitions] = useState12([]);
1739
+ const [loading, setLoading] = useState12(true);
1740
+ const [applying, setApplying] = useState12(false);
1741
+ const [error, setError] = useState12(null);
1742
+ useEffect8(() => {
1323
1743
  const fetchTransitions = async () => {
1324
1744
  const siteUrl = getJiraSiteUrl(repoPath);
1325
1745
  const creds = getJiraCredentials(repoPath);
@@ -1388,7 +1808,7 @@ function ChangeStatusModal({ repoPath, ticketKey, currentStatus, onComplete, onC
1388
1808
  }
1389
1809
 
1390
1810
  // src/components/jira/ConfigureJiraSiteModal.tsx
1391
- import { useState as useState5 } from "react";
1811
+ import { useState as useState13 } from "react";
1392
1812
  import { Box as Box7, Text as Text7, useInput as useInput6 } from "ink";
1393
1813
 
1394
1814
  // src/lib/editor.ts
@@ -1429,10 +1849,10 @@ function ConfigureJiraSiteModal({
1429
1849
  loading,
1430
1850
  error
1431
1851
  }) {
1432
- const [siteUrl, setSiteUrl] = useState5(initialSiteUrl ?? "");
1433
- const [email, setEmail] = useState5(initialEmail ?? "");
1434
- const [apiToken, setApiToken] = useState5("");
1435
- const [selectedItem, setSelectedItem] = useState5("siteUrl");
1852
+ const [siteUrl, setSiteUrl] = useState13(initialSiteUrl ?? "");
1853
+ const [email, setEmail] = useState13(initialEmail ?? "");
1854
+ const [apiToken, setApiToken] = useState13("");
1855
+ const [selectedItem, setSelectedItem] = useState13("siteUrl");
1436
1856
  const items = ["siteUrl", "email", "apiToken", "submit"];
1437
1857
  const canSubmit = siteUrl.trim() && email.trim() && apiToken.trim();
1438
1858
  useInput6(
@@ -1513,7 +1933,7 @@ function ConfigureJiraSiteModal({
1513
1933
  }
1514
1934
 
1515
1935
  // src/components/jira/LinkTicketModal.tsx
1516
- import { useState as useState6 } from "react";
1936
+ import { useState as useState14 } from "react";
1517
1937
  import { Box as Box9, Text as Text9, useInput as useInput8 } from "ink";
1518
1938
 
1519
1939
  // src/components/ui/TextInput.tsx
@@ -1548,7 +1968,7 @@ function TextInput({ value, onChange, placeholder, isActive, mask }) {
1548
1968
  // src/components/jira/LinkTicketModal.tsx
1549
1969
  import { jsx as jsx9, jsxs as jsxs9 } from "react/jsx-runtime";
1550
1970
  function LinkTicketModal({ onSubmit, onCancel, loading, error }) {
1551
- const [ticketInput, setTicketInput] = useState6("");
1971
+ const [ticketInput, setTicketInput] = useState14("");
1552
1972
  const canSubmit = ticketInput.trim().length > 0;
1553
1973
  useInput8(
1554
1974
  (_input, key) => {
@@ -1606,225 +2026,106 @@ function TicketItem({ ticketKey, summary, status, isHighlighted, isSelected }) {
1606
2026
 
1607
2027
  // src/components/jira/JiraView.tsx
1608
2028
  import { jsx as jsx11, jsxs as jsxs11 } from "react/jsx-runtime";
1609
- function JiraView({ isFocused, onModalChange, onKeybindingsChange, onLogUpdated }) {
1610
- const [repoPath, setRepoPath] = useState7(null);
1611
- const [currentBranch, setCurrentBranch] = useState7(null);
1612
- const [isRepo, setIsRepo] = useState7(null);
1613
- const [jiraState, setJiraState] = useState7("not_configured");
1614
- const [tickets, setTickets] = useState7([]);
1615
- const [highlightedIndex, setHighlightedIndex] = useState7(0);
1616
- const [showConfigureModal, setShowConfigureModal] = useState7(false);
1617
- const [showLinkModal, setShowLinkModal] = useState7(false);
1618
- const [showStatusModal, setShowStatusModal] = useState7(false);
1619
- const [loading, setLoading] = useState7({ configure: false, link: false });
1620
- const [errors, setErrors] = useState7({});
1621
- useEffect5(() => {
1622
- if (!isFocused) {
1623
- setShowConfigureModal(false);
1624
- setShowLinkModal(false);
1625
- setShowStatusModal(false);
1626
- setErrors({});
1627
- }
1628
- }, [isFocused]);
1629
- useEffect5(() => {
1630
- onModalChange == null ? void 0 : onModalChange(showConfigureModal || showLinkModal || showStatusModal);
1631
- }, [showConfigureModal, showLinkModal, showStatusModal, onModalChange]);
1632
- useEffect5(() => {
1633
- if (!isFocused || showConfigureModal || showLinkModal || showStatusModal) {
1634
- onKeybindingsChange == null ? void 0 : onKeybindingsChange([]);
1635
- return;
1636
- }
1637
- const bindings = [];
1638
- if (jiraState === "not_configured") {
1639
- bindings.push({ key: "c", label: "Configure Jira" });
1640
- } else if (jiraState === "no_tickets") {
1641
- bindings.push({ key: "l", label: "Link Ticket" });
1642
- } else if (jiraState === "has_tickets") {
1643
- bindings.push({ key: "l", label: "Link" });
1644
- bindings.push({ key: "s", label: "Status" });
1645
- bindings.push({ key: "d", label: "Unlink", color: "red" });
1646
- bindings.push({ key: "o", label: "Open", color: "green" });
1647
- bindings.push({ key: "y", label: "Copy Link" });
1648
- }
1649
- onKeybindingsChange == null ? void 0 : onKeybindingsChange(bindings);
1650
- }, [isFocused, jiraState, showConfigureModal, showLinkModal, showStatusModal, onKeybindingsChange]);
1651
- useEffect5(() => {
1652
- const gitRepoCheck = isGitRepo();
1653
- setIsRepo(gitRepoCheck);
1654
- if (!gitRepoCheck) return;
1655
- const rootResult = getRepoRoot();
1656
- if (rootResult.success) {
1657
- setRepoPath(rootResult.data);
1658
- }
1659
- const branchResult = getCurrentBranch();
1660
- if (branchResult.success) {
1661
- setCurrentBranch(branchResult.data);
1662
- }
1663
- }, []);
1664
- useEffect5(() => {
1665
- if (!repoPath || !currentBranch) return;
1666
- if (!isJiraConfigured(repoPath)) {
1667
- setJiraState("not_configured");
1668
- setTickets([]);
1669
- return;
2029
+ function JiraView({ isFocused, onModalChange, onJiraStateChange, onLogUpdated }) {
2030
+ const repo = useGitRepo();
2031
+ const jira = useJiraTickets();
2032
+ const modal = useModal();
2033
+ const nav = useListNavigation(jira.tickets.length);
2034
+ const lastInitRef = useRef5(null);
2035
+ useEffect9(() => {
2036
+ if (repo.loading || !repo.repoPath || !repo.currentBranch) return;
2037
+ const current = { branch: repo.currentBranch };
2038
+ const last = lastInitRef.current;
2039
+ if (last && last.branch === current.branch) return;
2040
+ lastInitRef.current = current;
2041
+ jira.initializeJiraState(repo.repoPath, repo.currentBranch, repo.currentRepoSlug);
2042
+ }, [repo.loading, repo.repoPath, repo.currentBranch, repo.currentRepoSlug, jira.initializeJiraState]);
2043
+ useEffect9(() => {
2044
+ if (isFocused) {
2045
+ repo.refreshBranch();
2046
+ } else {
2047
+ modal.close();
1670
2048
  }
1671
- const linkedTickets = getLinkedTickets(repoPath, currentBranch);
1672
- setTickets(linkedTickets);
1673
- setJiraState(linkedTickets.length > 0 ? "has_tickets" : "no_tickets");
1674
- }, [repoPath, currentBranch]);
1675
- useEffect5(() => {
1676
- if (!repoPath || !currentBranch) return;
1677
- if (jiraState !== "no_tickets") return;
1678
- const ticketKey = extractTicketKeyFromBranch(currentBranch);
1679
- if (!ticketKey) return;
1680
- const existingTickets = getLinkedTickets(repoPath, currentBranch);
1681
- if (existingTickets.some((t) => t.key === ticketKey)) return;
1682
- const siteUrl = getJiraSiteUrl(repoPath);
1683
- const creds = getJiraCredentials(repoPath);
1684
- if (!siteUrl || !creds.email || !creds.apiToken) return;
1685
- const auth = { siteUrl, email: creds.email, apiToken: creds.apiToken };
1686
- getIssue(auth, ticketKey).then((result) => {
1687
- if (result.success) {
1688
- const linkedTicket = {
1689
- key: result.data.key,
1690
- summary: result.data.fields.summary,
1691
- status: result.data.fields.status.name,
1692
- linkedAt: (/* @__PURE__ */ new Date()).toISOString()
1693
- };
1694
- addLinkedTicket(repoPath, currentBranch, linkedTicket);
1695
- setTickets([linkedTicket]);
1696
- setJiraState("has_tickets");
1697
- }
1698
- });
1699
- }, [repoPath, currentBranch, jiraState]);
1700
- const refreshTickets = useCallback2(() => {
1701
- if (!repoPath || !currentBranch) return;
1702
- const linkedTickets = getLinkedTickets(repoPath, currentBranch);
1703
- setTickets(linkedTickets);
1704
- setJiraState(linkedTickets.length > 0 ? "has_tickets" : "no_tickets");
1705
- }, [repoPath, currentBranch]);
1706
- const handleConfigureSubmit = useCallback2(
1707
- async (siteUrl, email, apiToken) => {
1708
- if (!repoPath) return;
1709
- setLoading((prev) => ({ ...prev, configure: true }));
1710
- setErrors((prev) => ({ ...prev, configure: void 0 }));
1711
- const auth = { siteUrl, email, apiToken };
1712
- const result = await validateCredentials(auth);
1713
- if (!result.success) {
1714
- setErrors((prev) => ({ ...prev, configure: result.error }));
1715
- setLoading((prev) => ({ ...prev, configure: false }));
1716
- return;
1717
- }
1718
- setJiraSiteUrl(repoPath, siteUrl);
1719
- setJiraCredentials(repoPath, email, apiToken);
1720
- setShowConfigureModal(false);
1721
- setJiraState("no_tickets");
1722
- setLoading((prev) => ({ ...prev, configure: false }));
1723
- },
1724
- [repoPath]
1725
- );
1726
- const handleLinkSubmit = useCallback2(
1727
- async (ticketInput) => {
1728
- if (!repoPath || !currentBranch) return;
1729
- setLoading((prev) => ({ ...prev, link: true }));
1730
- setErrors((prev) => ({ ...prev, link: void 0 }));
1731
- const ticketKey = parseTicketKey(ticketInput);
1732
- if (!ticketKey) {
1733
- setErrors((prev) => ({ ...prev, link: "Invalid ticket format. Use PROJ-123 or a Jira URL." }));
1734
- setLoading((prev) => ({ ...prev, link: false }));
1735
- return;
1736
- }
1737
- const siteUrl = getJiraSiteUrl(repoPath);
1738
- const creds = getJiraCredentials(repoPath);
1739
- if (!siteUrl || !creds.email || !creds.apiToken) {
1740
- setErrors((prev) => ({ ...prev, link: "Jira not configured" }));
1741
- setLoading((prev) => ({ ...prev, link: false }));
1742
- return;
1743
- }
1744
- const auth = { siteUrl, email: creds.email, apiToken: creds.apiToken };
1745
- const result = await getIssue(auth, ticketKey);
1746
- if (!result.success) {
1747
- setErrors((prev) => ({ ...prev, link: result.error }));
1748
- setLoading((prev) => ({ ...prev, link: false }));
1749
- return;
1750
- }
1751
- const linkedTicket = {
1752
- key: result.data.key,
1753
- summary: result.data.fields.summary,
1754
- status: result.data.fields.status.name,
1755
- linkedAt: (/* @__PURE__ */ new Date()).toISOString()
1756
- };
1757
- addLinkedTicket(repoPath, currentBranch, linkedTicket);
1758
- refreshTickets();
1759
- setShowLinkModal(false);
1760
- setLoading((prev) => ({ ...prev, link: false }));
1761
- },
1762
- [repoPath, currentBranch, refreshTickets]
1763
- );
1764
- const handleUnlinkTicket = useCallback2(() => {
1765
- if (!repoPath || !currentBranch || tickets.length === 0) return;
1766
- const ticket = tickets[highlightedIndex];
2049
+ }, [isFocused, repo.refreshBranch, modal.close]);
2050
+ useEffect9(() => {
2051
+ onModalChange == null ? void 0 : onModalChange(modal.isOpen);
2052
+ }, [modal.isOpen, onModalChange]);
2053
+ useEffect9(() => {
2054
+ onJiraStateChange == null ? void 0 : onJiraStateChange(jira.jiraState);
2055
+ }, [jira.jiraState, onJiraStateChange]);
2056
+ const handleConfigureSubmit = async (siteUrl, email, apiToken) => {
2057
+ if (!repo.repoPath) return;
2058
+ const success = await jira.configureJira(repo.repoPath, siteUrl, email, apiToken);
2059
+ if (success) modal.close();
2060
+ };
2061
+ const handleLinkSubmit = async (ticketInput) => {
2062
+ if (!repo.repoPath || !repo.currentBranch) return;
2063
+ const success = await jira.linkTicket(repo.repoPath, repo.currentBranch, ticketInput);
2064
+ if (success) modal.close();
2065
+ };
2066
+ const handleUnlinkTicket = () => {
2067
+ if (!repo.repoPath || !repo.currentBranch || jira.tickets.length === 0) return;
2068
+ const ticket = jira.tickets[nav.index];
1767
2069
  if (ticket) {
1768
- removeLinkedTicket(repoPath, currentBranch, ticket.key);
1769
- refreshTickets();
1770
- setHighlightedIndex((prev) => Math.max(0, prev - 1));
2070
+ jira.unlinkTicket(repo.repoPath, repo.currentBranch, ticket.key);
2071
+ jira.refreshTickets(repo.repoPath, repo.currentBranch);
2072
+ nav.prev();
1771
2073
  }
1772
- }, [repoPath, currentBranch, tickets, highlightedIndex, refreshTickets]);
1773
- const handleOpenInBrowser = useCallback2(() => {
1774
- if (!repoPath || tickets.length === 0) return;
1775
- const ticket = tickets[highlightedIndex];
1776
- const siteUrl = getJiraSiteUrl(repoPath);
2074
+ };
2075
+ const handleOpenInBrowser = () => {
2076
+ if (!repo.repoPath || jira.tickets.length === 0) return;
2077
+ const ticket = jira.tickets[nav.index];
2078
+ const siteUrl = getJiraSiteUrl(repo.repoPath);
1777
2079
  if (ticket && siteUrl) {
1778
- const url = `${siteUrl}/browse/${ticket.key}`;
1779
- open2(url).catch(() => {
2080
+ open2(`${siteUrl}/browse/${ticket.key}`).catch(() => {
1780
2081
  });
1781
2082
  }
1782
- }, [repoPath, tickets, highlightedIndex]);
2083
+ };
2084
+ const handleCopyLink = () => {
2085
+ if (!repo.repoPath || jira.tickets.length === 0) return;
2086
+ const ticket = jira.tickets[nav.index];
2087
+ const siteUrl = getJiraSiteUrl(repo.repoPath);
2088
+ if (ticket && siteUrl) {
2089
+ copyToClipboard(`${siteUrl}/browse/${ticket.key}`);
2090
+ }
2091
+ };
2092
+ const handleStatusComplete = (newStatus) => {
2093
+ if (!repo.repoPath || !repo.currentBranch) return;
2094
+ const ticket = jira.tickets[nav.index];
2095
+ if (!ticket) return;
2096
+ updateTicketStatus(repo.repoPath, repo.currentBranch, ticket.key, newStatus);
2097
+ logJiraStatusChanged(ticket.key, ticket.summary, ticket.status, newStatus);
2098
+ onLogUpdated == null ? void 0 : onLogUpdated();
2099
+ modal.close();
2100
+ jira.refreshTickets(repo.repoPath, repo.currentBranch);
2101
+ };
1783
2102
  useInput9(
1784
2103
  (input, key) => {
1785
- if (showConfigureModal || showLinkModal || showStatusModal) return;
1786
- if (input === "c" && jiraState === "not_configured") {
1787
- setShowConfigureModal(true);
2104
+ if (input === "c" && jira.jiraState === "not_configured") {
2105
+ modal.open("configure");
1788
2106
  return;
1789
2107
  }
1790
- if (input === "l" && jiraState !== "not_configured") {
1791
- setShowLinkModal(true);
2108
+ if (input === "l" && jira.jiraState !== "not_configured") {
2109
+ modal.open("link");
1792
2110
  return;
1793
2111
  }
1794
- if (jiraState === "has_tickets") {
1795
- if (key.upArrow || input === "k") {
1796
- setHighlightedIndex((prev) => Math.max(0, prev - 1));
1797
- }
1798
- if (key.downArrow || input === "j") {
1799
- setHighlightedIndex((prev) => Math.min(tickets.length - 1, prev + 1));
1800
- }
1801
- if (input === "s") {
1802
- setShowStatusModal(true);
1803
- }
1804
- if (input === "d") {
1805
- handleUnlinkTicket();
1806
- }
1807
- if (input === "o") {
1808
- handleOpenInBrowser();
1809
- }
1810
- if (input === "y" && repoPath) {
1811
- const ticket = tickets[highlightedIndex];
1812
- const siteUrl = getJiraSiteUrl(repoPath);
1813
- if (ticket && siteUrl) {
1814
- const url = `${siteUrl}/browse/${ticket.key}`;
1815
- copyToClipboard(url);
1816
- }
1817
- }
2112
+ if (jira.jiraState === "has_tickets") {
2113
+ if (key.upArrow || input === "k") nav.prev();
2114
+ if (key.downArrow || input === "j") nav.next();
2115
+ if (input === "s") modal.open("status");
2116
+ if (input === "d") handleUnlinkTicket();
2117
+ if (input === "o") handleOpenInBrowser();
2118
+ if (input === "y") handleCopyLink();
1818
2119
  }
1819
2120
  },
1820
- { isActive: isFocused && !showConfigureModal && !showLinkModal && !showStatusModal }
2121
+ { isActive: isFocused && !modal.isOpen }
1821
2122
  );
1822
- if (isRepo === false) {
2123
+ if (repo.isRepo === false) {
1823
2124
  return /* @__PURE__ */ jsx11(TitledBox4, { borderStyle: "round", titles: ["Jira"], flexShrink: 0, children: /* @__PURE__ */ jsx11(Text11, { color: "red", children: "Not a git repository" }) });
1824
2125
  }
1825
- if (showConfigureModal) {
1826
- const siteUrl = repoPath ? getJiraSiteUrl(repoPath) : void 0;
1827
- const creds = repoPath ? getJiraCredentials(repoPath) : { email: null, apiToken: null };
2126
+ if (modal.type === "configure") {
2127
+ const siteUrl = repo.repoPath ? getJiraSiteUrl(repo.repoPath) : void 0;
2128
+ const creds = repo.repoPath ? getJiraCredentials(repo.repoPath) : { email: null, apiToken: null };
1828
2129
  return /* @__PURE__ */ jsx11(Box11, { flexDirection: "column", flexShrink: 0, children: /* @__PURE__ */ jsx11(
1829
2130
  ConfigureJiraSiteModal,
1830
2131
  {
@@ -1832,60 +2133,53 @@ function JiraView({ isFocused, onModalChange, onKeybindingsChange, onLogUpdated
1832
2133
  initialEmail: creds.email ?? void 0,
1833
2134
  onSubmit: handleConfigureSubmit,
1834
2135
  onCancel: () => {
1835
- setShowConfigureModal(false);
1836
- setErrors((prev) => ({ ...prev, configure: void 0 }));
2136
+ modal.close();
2137
+ jira.clearError("configure");
1837
2138
  },
1838
- loading: loading.configure,
1839
- error: errors.configure
2139
+ loading: jira.loading.configure,
2140
+ error: jira.errors.configure
1840
2141
  }
1841
2142
  ) });
1842
2143
  }
1843
- if (showLinkModal) {
2144
+ if (modal.type === "link") {
1844
2145
  return /* @__PURE__ */ jsx11(Box11, { flexDirection: "column", flexShrink: 0, children: /* @__PURE__ */ jsx11(
1845
2146
  LinkTicketModal,
1846
2147
  {
1847
2148
  onSubmit: handleLinkSubmit,
1848
2149
  onCancel: () => {
1849
- setShowLinkModal(false);
1850
- setErrors((prev) => ({ ...prev, link: void 0 }));
2150
+ modal.close();
2151
+ jira.clearError("link");
1851
2152
  },
1852
- loading: loading.link,
1853
- error: errors.link
2153
+ loading: jira.loading.link,
2154
+ error: jira.errors.link
1854
2155
  }
1855
2156
  ) });
1856
2157
  }
1857
- if (showStatusModal && repoPath && currentBranch && tickets[highlightedIndex]) {
1858
- const ticket = tickets[highlightedIndex];
2158
+ if (modal.type === "status" && repo.repoPath && repo.currentBranch && jira.tickets[nav.index]) {
2159
+ const ticket = jira.tickets[nav.index];
1859
2160
  return /* @__PURE__ */ jsx11(Box11, { flexDirection: "column", flexShrink: 0, children: /* @__PURE__ */ jsx11(
1860
2161
  ChangeStatusModal,
1861
2162
  {
1862
- repoPath,
2163
+ repoPath: repo.repoPath,
1863
2164
  ticketKey: ticket.key,
1864
2165
  currentStatus: ticket.status,
1865
- onComplete: (newStatus) => {
1866
- const oldStatus = ticket.status;
1867
- updateTicketStatus(repoPath, currentBranch, ticket.key, newStatus);
1868
- logJiraStatusChanged(ticket.key, ticket.summary, oldStatus, newStatus);
1869
- onLogUpdated == null ? void 0 : onLogUpdated();
1870
- setShowStatusModal(false);
1871
- refreshTickets();
1872
- },
1873
- onCancel: () => setShowStatusModal(false)
2166
+ onComplete: handleStatusComplete,
2167
+ onCancel: modal.close
1874
2168
  }
1875
2169
  ) });
1876
2170
  }
1877
2171
  const title = "[4] Jira";
1878
2172
  const borderColor = isFocused ? "yellow" : void 0;
1879
2173
  return /* @__PURE__ */ jsx11(TitledBox4, { borderStyle: "round", titles: [title], borderColor, flexShrink: 0, children: /* @__PURE__ */ jsxs11(Box11, { flexDirection: "column", paddingX: 1, children: [
1880
- jiraState === "not_configured" && /* @__PURE__ */ jsx11(Text11, { dimColor: true, children: "No Jira site configured" }),
1881
- jiraState === "no_tickets" && /* @__PURE__ */ jsx11(Text11, { dimColor: true, children: "No tickets linked to this branch" }),
1882
- jiraState === "has_tickets" && tickets.map((ticket, idx) => /* @__PURE__ */ jsx11(
2174
+ jira.jiraState === "not_configured" && /* @__PURE__ */ jsx11(Text11, { dimColor: true, children: "No Jira site configured" }),
2175
+ jira.jiraState === "no_tickets" && /* @__PURE__ */ jsx11(Text11, { dimColor: true, children: "No tickets linked to this branch" }),
2176
+ jira.jiraState === "has_tickets" && jira.tickets.map((ticket, idx) => /* @__PURE__ */ jsx11(
1883
2177
  TicketItem,
1884
2178
  {
1885
2179
  ticketKey: ticket.key,
1886
2180
  summary: ticket.summary,
1887
2181
  status: ticket.status,
1888
- isHighlighted: idx === highlightedIndex
2182
+ isHighlighted: idx === nav.index
1889
2183
  },
1890
2184
  ticket.key
1891
2185
  ))
@@ -1893,7 +2187,7 @@ function JiraView({ isFocused, onModalChange, onKeybindingsChange, onLogUpdated
1893
2187
  }
1894
2188
 
1895
2189
  // src/components/logs/LogsView.tsx
1896
- import { useCallback as useCallback3, useEffect as useEffect6, useState as useState9 } from "react";
2190
+ import { useEffect as useEffect10 } from "react";
1897
2191
  import { Box as Box14, useInput as useInput12 } from "ink";
1898
2192
 
1899
2193
  // src/components/logs/LogsHistoryBox.tsx
@@ -1956,35 +2250,133 @@ function LogsHistoryBox({
1956
2250
  }
1957
2251
 
1958
2252
  // src/components/logs/LogViewerBox.tsx
1959
- import { useRef as useRef3, useState as useState8 } from "react";
2253
+ import { useRef as useRef6, useState as useState15 } from "react";
1960
2254
  import { TitledBox as TitledBox6 } from "@mishieck/ink-titled-box";
1961
2255
  import { Box as Box13, Text as Text13, useInput as useInput11 } from "ink";
1962
2256
  import { ScrollView as ScrollView2 } from "ink-scroll-view";
1963
2257
  import TextInput2 from "ink-text-input";
2258
+
2259
+ // src/lib/claude/api.ts
2260
+ import { exec as exec3 } from "child_process";
2261
+ function runClaudePrompt(prompt) {
2262
+ let childProcess = null;
2263
+ let cancelled = false;
2264
+ const promise = new Promise((resolve) => {
2265
+ const escapedPrompt = prompt.replace(/'/g, "'\\''").replace(/\n/g, "\\n");
2266
+ const command = `claude -p $'${escapedPrompt}' --output-format json < /dev/null`;
2267
+ childProcess = exec3(command, { maxBuffer: 10 * 1024 * 1024 }, (error, stdout, stderr) => {
2268
+ if (cancelled) {
2269
+ resolve({
2270
+ success: false,
2271
+ error: "Cancelled",
2272
+ errorType: "execution_error"
2273
+ });
2274
+ return;
2275
+ }
2276
+ if (error) {
2277
+ if (error.message.includes("command not found") || error.message.includes("ENOENT") || error.code === "ENOENT") {
2278
+ resolve({
2279
+ success: false,
2280
+ error: "Claude CLI not installed. Run: npm install -g @anthropic-ai/claude-code",
2281
+ errorType: "not_installed"
2282
+ });
2283
+ return;
2284
+ }
2285
+ resolve({
2286
+ success: false,
2287
+ error: (stderr == null ? void 0 : stderr.trim()) || error.message,
2288
+ errorType: "execution_error"
2289
+ });
2290
+ return;
2291
+ }
2292
+ if (!(stdout == null ? void 0 : stdout.trim())) {
2293
+ resolve({
2294
+ success: false,
2295
+ error: (stderr == null ? void 0 : stderr.trim()) || "Claude returned empty response",
2296
+ errorType: "execution_error"
2297
+ });
2298
+ return;
2299
+ }
2300
+ try {
2301
+ const json = JSON.parse(stdout.trim());
2302
+ if (json.is_error) {
2303
+ resolve({
2304
+ success: false,
2305
+ error: json.result || "Claude returned an error",
2306
+ errorType: "execution_error"
2307
+ });
2308
+ return;
2309
+ }
2310
+ resolve({
2311
+ success: true,
2312
+ data: json.result || stdout.trim()
2313
+ });
2314
+ } catch {
2315
+ resolve({
2316
+ success: true,
2317
+ data: stdout.trim()
2318
+ });
2319
+ }
2320
+ });
2321
+ });
2322
+ const cancel = () => {
2323
+ cancelled = true;
2324
+ if (childProcess) {
2325
+ childProcess.kill("SIGTERM");
2326
+ }
2327
+ };
2328
+ return { promise, cancel };
2329
+ }
2330
+ function generateStandupNotes(logContent) {
2331
+ const prompt = `You are helping a developer prepare standup notes. Based on the following log entries, generate concise standup notes that summarize what was accomplished.
2332
+
2333
+ Format the output as bullet points grouped by category (e.g., Features, Bug Fixes, Refactoring, etc.). Keep it brief and professional.
2334
+
2335
+ Log entries:
2336
+ ${logContent}
2337
+
2338
+ Generate the standup notes:`;
2339
+ return runClaudePrompt(prompt);
2340
+ }
2341
+
2342
+ // src/components/logs/LogViewerBox.tsx
1964
2343
  import { jsx as jsx13, jsxs as jsxs13 } from "react/jsx-runtime";
1965
2344
  function LogViewerBox({ date, content, isFocused, onRefresh, onLogCreated }) {
1966
- const scrollRef = useRef3(null);
1967
- const [isInputMode, setIsInputMode] = useState8(false);
1968
- const [inputValue, setInputValue] = useState8("");
2345
+ const scrollRef = useRef6(null);
2346
+ const [isInputMode, setIsInputMode] = useState15(false);
2347
+ const [inputValue, setInputValue] = useState15("");
2348
+ const [isGeneratingStandup, setIsGeneratingStandup] = useState15(false);
2349
+ const [standupResult, setStandupResult] = useState15(null);
2350
+ const claudeProcessRef = useRef6(null);
1969
2351
  const title = "[6] Log Content";
1970
2352
  const borderColor = isFocused ? "yellow" : void 0;
1971
2353
  const displayTitle = date ? `${title} - ${date}.md` : title;
1972
2354
  useInput11(
1973
2355
  (input, key) => {
1974
- var _a, _b;
2356
+ var _a, _b, _c;
1975
2357
  if (key.escape && isInputMode) {
1976
2358
  setIsInputMode(false);
1977
2359
  setInputValue("");
1978
2360
  return;
1979
2361
  }
1980
- if (isInputMode) {
2362
+ if (key.escape && isGeneratingStandup) {
2363
+ (_a = claudeProcessRef.current) == null ? void 0 : _a.cancel();
2364
+ claudeProcessRef.current = null;
2365
+ setIsGeneratingStandup(false);
2366
+ return;
2367
+ }
2368
+ if (key.escape && standupResult) {
2369
+ setStandupResult(null);
2370
+ return;
2371
+ }
2372
+ if (isInputMode || standupResult) {
1981
2373
  return;
1982
2374
  }
1983
2375
  if (key.upArrow || input === "k") {
1984
- (_a = scrollRef.current) == null ? void 0 : _a.scrollBy(-1);
2376
+ (_b = scrollRef.current) == null ? void 0 : _b.scrollBy(-1);
1985
2377
  }
1986
2378
  if (key.downArrow || input === "j") {
1987
- (_b = scrollRef.current) == null ? void 0 : _b.scrollBy(1);
2379
+ (_c = scrollRef.current) == null ? void 0 : _c.scrollBy(1);
1988
2380
  }
1989
2381
  if (input === "e" && date) {
1990
2382
  openLogInEditor(date);
@@ -2003,6 +2395,21 @@ function LogViewerBox({ date, content, isFocused, onRefresh, onLogCreated }) {
2003
2395
  if (input === "r") {
2004
2396
  onRefresh();
2005
2397
  }
2398
+ if (input === "c" && date && content && !isGeneratingStandup) {
2399
+ setIsGeneratingStandup(true);
2400
+ setStandupResult(null);
2401
+ const process2 = generateStandupNotes(content);
2402
+ claudeProcessRef.current = process2;
2403
+ process2.promise.then((result) => {
2404
+ claudeProcessRef.current = null;
2405
+ setIsGeneratingStandup(false);
2406
+ if (result.success) {
2407
+ setStandupResult({ type: "success", message: result.data });
2408
+ } else if (result.error !== "Cancelled") {
2409
+ setStandupResult({ type: "error", message: result.error });
2410
+ }
2411
+ });
2412
+ }
2006
2413
  },
2007
2414
  { isActive: isFocused }
2008
2415
  );
@@ -2037,98 +2444,35 @@ ${value.trim()}
2037
2444
  onChange: (val) => setInputValue(val.replace(/[\r\n]/g, "")),
2038
2445
  onSubmit: handleInputSubmit
2039
2446
  }
2040
- ) }) })
2447
+ ) }) }),
2448
+ isGeneratingStandup && /* @__PURE__ */ jsx13(TitledBox6, { borderStyle: "round", titles: ["Standup Notes"], borderColor: "yellow", children: /* @__PURE__ */ jsxs13(Box13, { paddingX: 1, flexDirection: "column", children: [
2449
+ /* @__PURE__ */ jsx13(Text13, { color: "yellow", children: "Generating standup notes..." }),
2450
+ /* @__PURE__ */ jsx13(Text13, { dimColor: true, children: "Press Esc to cancel" })
2451
+ ] }) }),
2452
+ standupResult && /* @__PURE__ */ jsx13(
2453
+ TitledBox6,
2454
+ {
2455
+ borderStyle: "round",
2456
+ titles: ["Standup Notes"],
2457
+ borderColor: standupResult.type === "error" ? "red" : "green",
2458
+ children: /* @__PURE__ */ jsxs13(Box13, { paddingX: 1, flexDirection: "column", children: [
2459
+ standupResult.type === "error" ? /* @__PURE__ */ jsx13(Text13, { color: "red", children: standupResult.message }) : /* @__PURE__ */ jsx13(Markdown, { children: standupResult.message }),
2460
+ /* @__PURE__ */ jsx13(Text13, { dimColor: true, children: "Press Esc to dismiss" })
2461
+ ] })
2462
+ }
2463
+ )
2041
2464
  ] });
2042
2465
  }
2043
2466
 
2044
2467
  // src/components/logs/LogsView.tsx
2045
2468
  import { jsx as jsx14, jsxs as jsxs14 } from "react/jsx-runtime";
2046
- function LogsView({ isFocused, onKeybindingsChange, refreshKey, focusedBox, onFocusedBoxChange }) {
2047
- const [logFiles, setLogFiles] = useState9([]);
2048
- const [selectedDate, setSelectedDate] = useState9(null);
2049
- const [logContent, setLogContent] = useState9(null);
2050
- const [highlightedIndex, setHighlightedIndex] = useState9(0);
2051
- useEffect6(() => {
2052
- if (!isFocused) {
2053
- onKeybindingsChange == null ? void 0 : onKeybindingsChange([]);
2054
- return;
2055
- }
2056
- const bindings = [];
2057
- if (focusedBox === "history") {
2058
- bindings.push({ key: "Enter", label: "Select" });
2059
- } else if (focusedBox === "viewer") {
2060
- bindings.push({ key: "i", label: "Add Entry" });
2061
- bindings.push({ key: "e", label: "Edit" });
2062
- bindings.push({ key: "n", label: "New Log", color: "green" });
2063
- bindings.push({ key: "r", label: "Refresh" });
2064
- }
2065
- onKeybindingsChange == null ? void 0 : onKeybindingsChange(bindings);
2066
- }, [isFocused, focusedBox, onKeybindingsChange]);
2067
- const refreshLogFiles = useCallback3(() => {
2068
- const files = listLogFiles();
2069
- setLogFiles(files);
2070
- if (files.length > 0 && !selectedDate) {
2071
- const today = getTodayDate();
2072
- const todayFile = files.find((f) => f.date === today);
2073
- if (todayFile) {
2074
- setSelectedDate(todayFile.date);
2075
- const idx = files.findIndex((f) => f.date === today);
2076
- setHighlightedIndex(idx >= 0 ? idx : 0);
2077
- } else {
2078
- setSelectedDate(files[0].date);
2079
- setHighlightedIndex(0);
2080
- }
2081
- }
2082
- }, [selectedDate]);
2083
- useEffect6(() => {
2084
- refreshLogFiles();
2085
- }, [refreshLogFiles]);
2086
- useEffect6(() => {
2087
- if (selectedDate) {
2088
- const content = readLog(selectedDate);
2089
- setLogContent(content);
2090
- } else {
2091
- setLogContent(null);
2092
- }
2093
- }, [selectedDate]);
2094
- useEffect6(() => {
2469
+ function LogsView({ isFocused, refreshKey, focusedBox, onFocusedBoxChange }) {
2470
+ const logs = useLogs();
2471
+ useEffect10(() => {
2095
2472
  if (refreshKey !== void 0 && refreshKey > 0) {
2096
- const files = listLogFiles();
2097
- setLogFiles(files);
2098
- const today = getTodayDate();
2099
- if (selectedDate === today) {
2100
- const content = readLog(today);
2101
- setLogContent(content);
2102
- } else if (!selectedDate && files.length > 0) {
2103
- const todayFile = files.find((f) => f.date === today);
2104
- if (todayFile) {
2105
- setSelectedDate(today);
2106
- const idx = files.findIndex((f) => f.date === today);
2107
- setHighlightedIndex(idx >= 0 ? idx : 0);
2108
- }
2109
- }
2473
+ logs.handleExternalLogUpdate();
2110
2474
  }
2111
- }, [refreshKey, selectedDate]);
2112
- const handleSelectDate = useCallback3((date) => {
2113
- setSelectedDate(date);
2114
- }, []);
2115
- const handleRefresh = useCallback3(() => {
2116
- refreshLogFiles();
2117
- if (selectedDate) {
2118
- const content = readLog(selectedDate);
2119
- setLogContent(content);
2120
- }
2121
- }, [refreshLogFiles, selectedDate]);
2122
- const handleLogCreated = useCallback3(() => {
2123
- const files = listLogFiles();
2124
- setLogFiles(files);
2125
- const today = getTodayDate();
2126
- setSelectedDate(today);
2127
- const idx = files.findIndex((f) => f.date === today);
2128
- setHighlightedIndex(idx >= 0 ? idx : 0);
2129
- const content = readLog(today);
2130
- setLogContent(content);
2131
- }, []);
2475
+ }, [refreshKey, logs.handleExternalLogUpdate]);
2132
2476
  useInput12(
2133
2477
  (input) => {
2134
2478
  if (input === "5") onFocusedBoxChange("history");
@@ -2140,22 +2484,22 @@ function LogsView({ isFocused, onKeybindingsChange, refreshKey, focusedBox, onFo
2140
2484
  /* @__PURE__ */ jsx14(
2141
2485
  LogsHistoryBox,
2142
2486
  {
2143
- logFiles,
2144
- selectedDate,
2145
- highlightedIndex,
2146
- onHighlight: setHighlightedIndex,
2147
- onSelect: handleSelectDate,
2487
+ logFiles: logs.logFiles,
2488
+ selectedDate: logs.selectedDate,
2489
+ highlightedIndex: logs.highlightedIndex,
2490
+ onHighlight: logs.setHighlightedIndex,
2491
+ onSelect: logs.selectDate,
2148
2492
  isFocused: isFocused && focusedBox === "history"
2149
2493
  }
2150
2494
  ),
2151
2495
  /* @__PURE__ */ jsx14(
2152
2496
  LogViewerBox,
2153
2497
  {
2154
- date: selectedDate,
2155
- content: logContent,
2498
+ date: logs.selectedDate,
2499
+ content: logs.logContent,
2156
2500
  isFocused: isFocused && focusedBox === "viewer",
2157
- onRefresh: handleRefresh,
2158
- onLogCreated: handleLogCreated
2501
+ onRefresh: logs.refresh,
2502
+ onLogCreated: logs.handleLogCreated
2159
2503
  }
2160
2504
  )
2161
2505
  ] });
@@ -2180,16 +2524,85 @@ function KeybindingsBar({ contextBindings = [], modalOpen = false }) {
2180
2524
  ] }, binding.key)) });
2181
2525
  }
2182
2526
 
2527
+ // src/constants/github.ts
2528
+ var GITHUB_KEYBINDINGS = {
2529
+ remotes: [
2530
+ { key: "Space", label: "Select Remote" }
2531
+ ],
2532
+ prs: [
2533
+ { key: "Space", label: "Select" },
2534
+ { key: "n", label: "New PR", color: "green" },
2535
+ { key: "r", label: "Refresh" },
2536
+ { key: "o", label: "Open", color: "green" },
2537
+ { key: "y", label: "Copy Link" }
2538
+ ],
2539
+ details: [
2540
+ { key: "r", label: "Refresh" },
2541
+ { key: "o", label: "Open", color: "green" }
2542
+ ]
2543
+ };
2544
+
2545
+ // src/constants/jira.ts
2546
+ var JIRA_KEYBINDINGS = {
2547
+ not_configured: [{ key: "c", label: "Configure Jira" }],
2548
+ no_tickets: [{ key: "l", label: "Link Ticket" }],
2549
+ has_tickets: [
2550
+ { key: "l", label: "Link" },
2551
+ { key: "s", label: "Status" },
2552
+ { key: "d", label: "Unlink", color: "red" },
2553
+ { key: "o", label: "Open", color: "green" },
2554
+ { key: "y", label: "Copy Link" }
2555
+ ]
2556
+ };
2557
+
2558
+ // src/constants/logs.ts
2559
+ var LOGS_KEYBINDINGS = {
2560
+ history: [
2561
+ { key: "Enter", label: "Select" }
2562
+ ],
2563
+ viewer: [
2564
+ { key: "i", label: "Add Entry" },
2565
+ { key: "e", label: "Edit" },
2566
+ { key: "n", label: "New Log", color: "green" },
2567
+ { key: "c", label: "Standup" },
2568
+ { key: "r", label: "Refresh" }
2569
+ ]
2570
+ };
2571
+
2572
+ // src/lib/keybindings.ts
2573
+ function computeKeybindings(focusedView, state) {
2574
+ switch (focusedView) {
2575
+ case "github":
2576
+ return GITHUB_KEYBINDINGS[state.github.focusedBox];
2577
+ case "jira":
2578
+ if (state.jira.modalOpen) return [];
2579
+ return JIRA_KEYBINDINGS[state.jira.jiraState];
2580
+ case "logs":
2581
+ return LOGS_KEYBINDINGS[state.logs.focusedBox];
2582
+ default:
2583
+ return [];
2584
+ }
2585
+ }
2586
+
2183
2587
  // src/app.tsx
2184
2588
  import { jsx as jsx16, jsxs as jsxs16 } from "react/jsx-runtime";
2185
2589
  function App() {
2186
2590
  const { exit } = useApp();
2187
- const [focusedView, setFocusedView] = useState10("github");
2188
- const [modalOpen, setModalOpen] = useState10(false);
2189
- const [contextBindings, setContextBindings] = useState10([]);
2190
- const [logRefreshKey, setLogRefreshKey] = useState10(0);
2191
- const [logsFocusedBox, setLogsFocusedBox] = useState10("history");
2192
- const handleLogUpdated = useCallback4(() => {
2591
+ const [focusedView, setFocusedView] = useState16("github");
2592
+ const [modalOpen, setModalOpen] = useState16(false);
2593
+ const [logRefreshKey, setLogRefreshKey] = useState16(0);
2594
+ const [githubFocusedBox, setGithubFocusedBox] = useState16("remotes");
2595
+ const [jiraState, setJiraState] = useState16("not_configured");
2596
+ const [logsFocusedBox, setLogsFocusedBox] = useState16("history");
2597
+ const keybindings = useMemo2(
2598
+ () => computeKeybindings(focusedView, {
2599
+ github: { focusedBox: githubFocusedBox },
2600
+ jira: { jiraState, modalOpen },
2601
+ logs: { focusedBox: logsFocusedBox }
2602
+ }),
2603
+ [focusedView, githubFocusedBox, jiraState, modalOpen, logsFocusedBox]
2604
+ );
2605
+ const handleLogUpdated = useCallback9(() => {
2193
2606
  setLogRefreshKey((prev) => prev + 1);
2194
2607
  }, []);
2195
2608
  useInput13(
@@ -2221,7 +2634,7 @@ function App() {
2221
2634
  GitHubView,
2222
2635
  {
2223
2636
  isFocused: focusedView === "github",
2224
- onKeybindingsChange: focusedView === "github" ? setContextBindings : void 0,
2637
+ onFocusedBoxChange: setGithubFocusedBox,
2225
2638
  onLogUpdated: handleLogUpdated
2226
2639
  }
2227
2640
  ),
@@ -2230,7 +2643,7 @@ function App() {
2230
2643
  {
2231
2644
  isFocused: focusedView === "jira",
2232
2645
  onModalChange: setModalOpen,
2233
- onKeybindingsChange: focusedView === "jira" ? setContextBindings : void 0,
2646
+ onJiraStateChange: setJiraState,
2234
2647
  onLogUpdated: handleLogUpdated
2235
2648
  }
2236
2649
  )
@@ -2239,14 +2652,13 @@ function App() {
2239
2652
  LogsView,
2240
2653
  {
2241
2654
  isFocused: focusedView === "logs",
2242
- onKeybindingsChange: focusedView === "logs" ? setContextBindings : void 0,
2243
2655
  refreshKey: logRefreshKey,
2244
2656
  focusedBox: logsFocusedBox,
2245
2657
  onFocusedBoxChange: setLogsFocusedBox
2246
2658
  }
2247
2659
  ) })
2248
2660
  ] }),
2249
- /* @__PURE__ */ jsx16(KeybindingsBar, { contextBindings, modalOpen })
2661
+ /* @__PURE__ */ jsx16(KeybindingsBar, { contextBindings: keybindings, modalOpen })
2250
2662
  ] });
2251
2663
  }
2252
2664
 
@@ -2255,16 +2667,16 @@ import { render as inkRender } from "ink";
2255
2667
 
2256
2668
  // src/lib/Screen.tsx
2257
2669
  import { Box as Box17, useStdout as useStdout2 } from "ink";
2258
- import { useCallback as useCallback5, useEffect as useEffect7, useState as useState11 } from "react";
2670
+ import { useCallback as useCallback10, useEffect as useEffect11, useState as useState17 } from "react";
2259
2671
  import { jsx as jsx17 } from "react/jsx-runtime";
2260
2672
  function Screen({ children }) {
2261
2673
  const { stdout } = useStdout2();
2262
- const getSize = useCallback5(
2674
+ const getSize = useCallback10(
2263
2675
  () => ({ height: stdout.rows, width: stdout.columns }),
2264
2676
  [stdout]
2265
2677
  );
2266
- const [size, setSize] = useState11(getSize);
2267
- useEffect7(() => {
2678
+ const [size, setSize] = useState17(getSize);
2679
+ useEffect11(() => {
2268
2680
  const onResize = () => setSize(getSize());
2269
2681
  stdout.on("resize", onResize);
2270
2682
  return () => {