@colmbus72/yeehaw 0.5.0 → 0.6.1

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 (45) hide show
  1. package/claude-plugin/skills/yeehaw-development/SKILL.md +70 -0
  2. package/dist/app.js +166 -15
  3. package/dist/components/CritterHeader.d.ts +7 -0
  4. package/dist/components/CritterHeader.js +81 -0
  5. package/dist/components/List.d.ts +2 -0
  6. package/dist/components/List.js +1 -1
  7. package/dist/components/Panel.js +1 -1
  8. package/dist/components/ScrollableMarkdown.js +1 -1
  9. package/dist/lib/auth/index.d.ts +2 -0
  10. package/dist/lib/auth/index.js +3 -0
  11. package/dist/lib/auth/linear.d.ts +20 -0
  12. package/dist/lib/auth/linear.js +79 -0
  13. package/dist/lib/auth/storage.d.ts +12 -0
  14. package/dist/lib/auth/storage.js +53 -0
  15. package/dist/lib/context.d.ts +10 -0
  16. package/dist/lib/context.js +63 -0
  17. package/dist/lib/critters.d.ts +33 -0
  18. package/dist/lib/critters.js +164 -0
  19. package/dist/lib/hotkeys.d.ts +1 -1
  20. package/dist/lib/hotkeys.js +6 -2
  21. package/dist/lib/issues/github.d.ts +11 -0
  22. package/dist/lib/issues/github.js +154 -0
  23. package/dist/lib/issues/index.d.ts +14 -0
  24. package/dist/lib/issues/index.js +27 -0
  25. package/dist/lib/issues/linear.d.ts +24 -0
  26. package/dist/lib/issues/linear.js +345 -0
  27. package/dist/lib/issues/types.d.ts +82 -0
  28. package/dist/lib/issues/types.js +2 -0
  29. package/dist/lib/paths.d.ts +1 -0
  30. package/dist/lib/paths.js +1 -0
  31. package/dist/lib/tmux.d.ts +1 -0
  32. package/dist/lib/tmux.js +50 -1
  33. package/dist/types.d.ts +19 -0
  34. package/dist/views/BarnContext.d.ts +2 -1
  35. package/dist/views/BarnContext.js +136 -14
  36. package/dist/views/CritterDetailView.d.ts +10 -0
  37. package/dist/views/CritterDetailView.js +117 -0
  38. package/dist/views/CritterLogsView.d.ts +8 -0
  39. package/dist/views/CritterLogsView.js +100 -0
  40. package/dist/views/IssuesView.d.ts +2 -1
  41. package/dist/views/IssuesView.js +775 -103
  42. package/dist/views/LivestockDetailView.d.ts +2 -1
  43. package/dist/views/LivestockDetailView.js +8 -1
  44. package/dist/views/ProjectContext.js +35 -1
  45. package/package.json +1 -1
@@ -1,135 +1,616 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useState, useEffect } from 'react';
3
- import { Box, Text, useInput } from 'ink';
2
+ // src/views/IssuesView.tsx
3
+ import { useState, useEffect, useCallback } from 'react';
4
+ import { Box, Text, useInput, useStdout } from 'ink';
5
+ import TextInput from 'ink-text-input';
6
+ import { join } from 'path';
7
+ import { homedir } from 'os';
4
8
  import { Header } from '../components/Header.js';
5
9
  import { Panel } from '../components/Panel.js';
6
10
  import { List } from '../components/List.js';
7
11
  import { ScrollableMarkdown } from '../components/ScrollableMarkdown.js';
8
- import { parseGitHubUrl, fetchGitHubIssues, openIssueInBrowser, isGhCliAvailable, } from '../lib/github.js';
9
- export function IssuesView({ project, onBack }) {
12
+ import { getProvider, } from '../lib/issues/index.js';
13
+ import { saveProject } from '../lib/config.js';
14
+ import { buildProjectContext } from '../lib/context.js';
15
+ import { openIssueInBrowser } from '../lib/github.js';
16
+ import { saveLinearApiKey, validateLinearApiKey, LINEAR_API_KEY_URL, clearLinearToken } from '../lib/auth/linear.js';
17
+ // Expand ~ in paths to home directory
18
+ function expandPath(path) {
19
+ if (path.startsWith('~/')) {
20
+ return join(homedir(), path.slice(2));
21
+ }
22
+ return path;
23
+ }
24
+ // Status indicator based on Linear state type
25
+ function getStatusIndicator(stateType) {
26
+ switch (stateType) {
27
+ case 'backlog':
28
+ case 'triage':
29
+ return { char: '░', color: 'gray' };
30
+ case 'unstarted':
31
+ return { char: '░', color: 'gray' };
32
+ case 'started':
33
+ return { char: '▒', color: 'yellow' };
34
+ case 'completed':
35
+ return { char: '█', color: 'blue' };
36
+ case 'canceled':
37
+ return { char: '░', color: 'red' };
38
+ default:
39
+ return { char: ' ', color: 'gray' };
40
+ }
41
+ }
42
+ // Priority indicator: _ . : ! for low/med/high/urgent
43
+ function getPriorityIndicator(priority) {
44
+ switch (priority) {
45
+ case 1: return '!'; // urgent
46
+ case 2: return ':'; // high
47
+ case 3: return '.'; // medium
48
+ case 4: return '_'; // low
49
+ default: return ' '; // no priority
50
+ }
51
+ }
52
+ // Get initials from name (first letter of first and last name)
53
+ function getInitials(name) {
54
+ if (!name)
55
+ return ' ';
56
+ const parts = name.trim().split(/\s+/);
57
+ if (parts.length === 1) {
58
+ return parts[0].substring(0, 2).toUpperCase();
59
+ }
60
+ return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
61
+ }
62
+ export function IssuesView({ project, onBack, onOpenClaude }) {
63
+ const { stdout } = useStdout();
10
64
  const [focusedPanel, setFocusedPanel] = useState('list');
11
65
  const [selectedIndex, setSelectedIndex] = useState(0);
12
- const [issues, setIssues] = useState([]);
13
- const [loading, setLoading] = useState(true);
14
- const [error, setError] = useState(null);
15
- // Extract unique GitHub repos from local livestock
16
- const getLocalRepos = () => {
17
- const livestock = project.livestock || [];
18
- const localLivestock = livestock.filter((l) => !l.barn && l.repo);
19
- const seen = new Set();
20
- const repos = [];
21
- for (const l of localLivestock) {
22
- if (!l.repo)
23
- continue; // TypeScript narrows l.repo to string
24
- const parsed = parseGitHubUrl(l.repo);
25
- if (parsed) {
26
- const key = `${parsed.owner}/${parsed.repo}`;
27
- if (!seen.has(key)) {
28
- seen.add(key);
29
- repos.push({ ...parsed, name: l.name });
30
- }
31
- }
32
- }
33
- return repos;
34
- };
35
- // Fetch issues on mount
36
- useEffect(() => {
37
- const loadIssues = () => {
38
- setLoading(true);
39
- setError(null);
40
- if (!isGhCliAvailable()) {
41
- setError('GitHub CLI (gh) not found or not authenticated. Run: gh auth login');
42
- setLoading(false);
43
- return;
66
+ const [viewState, setViewState] = useState({ type: 'loading' });
67
+ const [linearProvider, setLinearProvider] = useState(null);
68
+ const [apiKeyInput, setApiKeyInput] = useState('');
69
+ // Calculate dynamic height for ScrollableMarkdown based on terminal size
70
+ // Layout: Header(3) + FilterInfo(1) + PanelBorders(2) + PanelTitle(1) + PanelHints(1) + Padding(2) = ~10 lines overhead
71
+ const terminalHeight = stdout?.rows ?? 24;
72
+ const panelContentHeight = Math.max(8, terminalHeight - 10);
73
+ // Filter state for Linear
74
+ const [cycles, setCycles] = useState([]);
75
+ const [assignees, setAssignees] = useState([]);
76
+ const [currentFilter, setCurrentFilter] = useState({
77
+ stateType: ['backlog', 'unstarted', 'started'], // Default to open issues
78
+ sortBy: 'priority',
79
+ sortDirection: 'desc',
80
+ });
81
+ const [filterInitialized, setFilterInitialized] = useState(false);
82
+ // Filter UI state
83
+ const [filterField, setFilterField] = useState('assignee');
84
+ const [filterSelectedIndex, setFilterSelectedIndex] = useState(0);
85
+ // Team name for display
86
+ const [teamName, setTeamName] = useState();
87
+ // Cached current user ID for filter
88
+ const [currentUserId, setCurrentUserId] = useState(null);
89
+ // Track if user explicitly selected "Me" filter (separate from actual ID)
90
+ // Default to true since we want "assigned to me" as the default filter
91
+ const [filterByMe, setFilterByMe] = useState(true);
92
+ const loadIssues = useCallback(async (filter) => {
93
+ setViewState({ type: 'loading' });
94
+ const provider = getProvider(project);
95
+ if (!provider) {
96
+ setViewState({ type: 'disabled' });
97
+ return;
98
+ }
99
+ // Check authentication
100
+ const isAuthed = await provider.isAuthenticated();
101
+ if (!isAuthed) {
102
+ setViewState({ type: 'not-authenticated', providerType: provider.type });
103
+ if (provider.type === 'linear') {
104
+ setLinearProvider(provider);
44
105
  }
45
- const repos = getLocalRepos();
46
- if (repos.length === 0) {
47
- setError('No GitHub repos found in local livestock');
48
- setLoading(false);
49
- return;
106
+ return;
107
+ }
108
+ // For Linear, check if team selection is needed
109
+ if (provider.type === 'linear') {
110
+ const linearProv = provider;
111
+ setLinearProvider(linearProv);
112
+ if (linearProv.needsTeamSelection()) {
113
+ try {
114
+ const teams = await linearProv.fetchTeams();
115
+ setViewState({ type: 'select-team', teams });
116
+ return;
117
+ }
118
+ catch (err) {
119
+ setViewState({ type: 'error', message: `Failed to fetch teams: ${err}` });
120
+ return;
121
+ }
50
122
  }
51
- const allIssues = [];
52
- for (const repo of repos) {
53
- const repoIssues = fetchGitHubIssues(repo.owner, repo.repo);
54
- for (const issue of repoIssues) {
55
- allIssues.push({
56
- ...issue,
57
- repoName: repo.name,
58
- fullRepo: `${repo.owner}/${repo.repo}`,
59
- });
60
- }
61
- }
62
- // Sort by updated date (most recent first)
63
- allIssues.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
64
- setIssues(allIssues);
65
- setLoading(false);
66
- };
123
+ // Set team name from provider (loaded from config)
124
+ setTeamName(linearProv.getTeamName());
125
+ // Initialize default filter (current cycle, assigned to me)
126
+ if (!filterInitialized) {
127
+ try {
128
+ // Fetch cycles, assignees, and user ID in parallel
129
+ const [fetchedCycles, fetchedAssignees, userId] = await Promise.all([
130
+ linearProv.fetchCycles(),
131
+ linearProv.fetchAssignees(),
132
+ linearProv.getCurrentUserId(),
133
+ ]);
134
+ setCycles(fetchedCycles);
135
+ setAssignees(fetchedAssignees);
136
+ setCurrentUserId(userId);
137
+ // Set default filter: current cycle, assigned to me, open issues
138
+ const activeCycleId = linearProv.getActiveCycleId();
139
+ const defaultFilter = {
140
+ cycleId: activeCycleId,
141
+ stateType: ['backlog', 'unstarted', 'started'],
142
+ sortBy: 'priority',
143
+ sortDirection: 'desc',
144
+ };
145
+ // Only add assigneeId if we have a valid user ID
146
+ if (userId) {
147
+ defaultFilter.assigneeId = userId;
148
+ setFilterByMe(true);
149
+ }
150
+ else {
151
+ // Couldn't get user ID, so can't filter by "me"
152
+ setFilterByMe(false);
153
+ }
154
+ setCurrentFilter(defaultFilter);
155
+ setFilterInitialized(true);
156
+ filter = defaultFilter;
157
+ }
158
+ catch (err) {
159
+ // Continue without filter if fetch fails
160
+ setFilterInitialized(true);
161
+ }
162
+ }
163
+ }
164
+ // Fetch issues
165
+ try {
166
+ const issues = await provider.fetchIssues({
167
+ linearFilter: filter ?? currentFilter,
168
+ });
169
+ setViewState({ type: 'ready', issues });
170
+ }
171
+ catch (err) {
172
+ setViewState({ type: 'error', message: `Failed to fetch issues: ${err}` });
173
+ }
174
+ }, [project, currentFilter, filterInitialized]);
175
+ useEffect(() => {
67
176
  loadIssues();
68
- }, [project]);
69
- const selectedIssue = issues[selectedIndex];
177
+ }, []); // Only run once on mount
178
+ const selectedIssue = viewState.type === 'ready' ? viewState.issues[selectedIndex] : null;
179
+ // Handle API key submission
180
+ const handleApiKeySubmit = async () => {
181
+ if (!apiKeyInput.trim())
182
+ return;
183
+ setViewState({ type: 'linear-auth-validating' });
184
+ const isValid = await validateLinearApiKey(apiKeyInput.trim());
185
+ if (isValid) {
186
+ saveLinearApiKey(apiKeyInput.trim());
187
+ setApiKeyInput('');
188
+ loadIssues();
189
+ }
190
+ else {
191
+ setViewState({ type: 'error', message: 'Invalid API key. Please check and try again.' });
192
+ }
193
+ };
194
+ // Handle team selection
195
+ const selectTeam = async (team) => {
196
+ if (!linearProvider)
197
+ return;
198
+ linearProvider.setTeamId(team.id);
199
+ linearProvider.setTeamName(team.name);
200
+ setTeamName(team.name);
201
+ // Save team ID and name to project config
202
+ const updatedProject = {
203
+ ...project,
204
+ issueProvider: { type: 'linear', teamId: team.id, teamName: team.name },
205
+ };
206
+ saveProject(updatedProject);
207
+ // Continue with existing provider (don't call loadIssues which would create new provider from stale prop)
208
+ setViewState({ type: 'loading' });
209
+ try {
210
+ // Fetch cycles, assignees, and user ID
211
+ const [fetchedCycles, fetchedAssignees, userId] = await Promise.all([
212
+ linearProvider.fetchCycles(),
213
+ linearProvider.fetchAssignees(),
214
+ linearProvider.getCurrentUserId(),
215
+ ]);
216
+ setCycles(fetchedCycles);
217
+ setAssignees(fetchedAssignees);
218
+ setCurrentUserId(userId);
219
+ // Set default filter: current cycle, assigned to me, open issues
220
+ const activeCycleId = linearProvider.getActiveCycleId();
221
+ const defaultFilter = {
222
+ cycleId: activeCycleId,
223
+ stateType: ['backlog', 'unstarted', 'started'],
224
+ sortBy: 'priority',
225
+ sortDirection: 'desc',
226
+ };
227
+ // Only add assigneeId if we have a valid user ID
228
+ if (userId) {
229
+ defaultFilter.assigneeId = userId;
230
+ setFilterByMe(true);
231
+ }
232
+ else {
233
+ // Couldn't get user ID, so can't filter by "me"
234
+ setFilterByMe(false);
235
+ }
236
+ setCurrentFilter(defaultFilter);
237
+ setFilterInitialized(true);
238
+ // Fetch issues
239
+ const issues = await linearProvider.fetchIssues({ linearFilter: defaultFilter });
240
+ setViewState({ type: 'ready', issues });
241
+ }
242
+ catch (err) {
243
+ setViewState({ type: 'error', message: `Failed to load issues: ${err}` });
244
+ }
245
+ };
246
+ // Build Claude context for an issue (includes project context)
247
+ const buildClaudeContext = (issue) => {
248
+ const lines = [];
249
+ // Include project context first (wiki sections, summary, etc.)
250
+ const projectContext = buildProjectContext(project.name);
251
+ if (projectContext) {
252
+ lines.push(projectContext);
253
+ lines.push('');
254
+ lines.push('---');
255
+ lines.push('');
256
+ }
257
+ lines.push('You are working on the following issue:');
258
+ lines.push('');
259
+ lines.push(`Title: ${issue.title}`);
260
+ lines.push(`Identifier: ${issue.identifier}`);
261
+ lines.push(`State: ${issue.state}`);
262
+ lines.push(`Author: ${issue.author}`);
263
+ lines.push(`URL: ${issue.url}`);
264
+ if (issue.priority !== undefined && issue.priority > 0) {
265
+ const priorityNames = ['', 'Urgent', 'High', 'Medium', 'Low'];
266
+ lines.push(`Priority: ${priorityNames[issue.priority]}`);
267
+ }
268
+ if (issue.assignee) {
269
+ lines.push(`Assignee: ${issue.assignee.name}`);
270
+ }
271
+ if (issue.estimate !== undefined) {
272
+ lines.push(`Points: ${issue.estimate}`);
273
+ }
274
+ lines.push('');
275
+ lines.push('Description:');
276
+ lines.push(issue.body || '(No description provided)');
277
+ lines.push('');
278
+ if (issue.labels.length > 0) {
279
+ lines.push(`Labels: ${issue.labels.join(', ')}`);
280
+ lines.push('');
281
+ }
282
+ if (issue.comments.length > 0) {
283
+ lines.push('---');
284
+ lines.push('Comments:');
285
+ lines.push('');
286
+ for (const comment of issue.comments) {
287
+ const date = new Date(comment.createdAt).toLocaleDateString();
288
+ lines.push(`${comment.author} (${date}):`);
289
+ lines.push(comment.body);
290
+ lines.push('');
291
+ lines.push('---');
292
+ }
293
+ }
294
+ return lines.join('\n');
295
+ };
296
+ // Apply filter and reload
297
+ const applyFilter = (newFilter) => {
298
+ setCurrentFilter(newFilter);
299
+ setViewState({ type: 'loading' });
300
+ loadIssues(newFilter);
301
+ };
70
302
  // Handle input
71
303
  useInput((input, key) => {
304
+ // Don't intercept input during text input mode
305
+ if (viewState.type === 'linear-auth-input') {
306
+ if (key.escape) {
307
+ setApiKeyInput('');
308
+ setViewState({ type: 'not-authenticated', providerType: 'linear' });
309
+ }
310
+ return;
311
+ }
312
+ // Filter view navigation
313
+ if (viewState.type === 'filter') {
314
+ if (key.escape) {
315
+ loadIssues(currentFilter);
316
+ return;
317
+ }
318
+ // Tab: select current option AND move to next field
319
+ if (key.tab) {
320
+ // First, select the current option (without applying yet)
321
+ selectFilterOptionLocal(filterField, filterSelectedIndex);
322
+ // Then move to next field
323
+ const fields = ['assignee', 'cycle', 'status', 'sort'];
324
+ const currentIndex = fields.indexOf(filterField);
325
+ setFilterField(fields[(currentIndex + 1) % fields.length]);
326
+ setFilterSelectedIndex(0);
327
+ return;
328
+ }
329
+ // Navigate options with j/k
330
+ if (input === 'j' || key.downArrow) {
331
+ const options = getFilterOptions(filterField);
332
+ setFilterSelectedIndex((prev) => Math.min(prev + 1, options.length - 1));
333
+ return;
334
+ }
335
+ if (input === 'k' || key.upArrow) {
336
+ setFilterSelectedIndex((prev) => Math.max(prev - 1, 0));
337
+ return;
338
+ }
339
+ // Enter: select current option, apply filter, and exit
340
+ if (key.return) {
341
+ const newFilter = selectFilterOptionLocal(filterField, filterSelectedIndex);
342
+ applyFilter(newFilter);
343
+ return;
344
+ }
345
+ return;
346
+ }
72
347
  if (key.escape) {
73
348
  onBack();
74
349
  return;
75
350
  }
76
- // Tab to switch focus between panels
351
+ // Handle auth prompt - press Enter to start API key input
352
+ if (viewState.type === 'not-authenticated' && viewState.providerType === 'linear') {
353
+ if (key.return) {
354
+ setViewState({ type: 'linear-auth-input' });
355
+ }
356
+ return;
357
+ }
358
+ // Handle error state - 'r' to retry, 'a' to re-authenticate
359
+ if (viewState.type === 'error') {
360
+ if (input === 'r') {
361
+ loadIssues();
362
+ return;
363
+ }
364
+ if (input === 'a') {
365
+ clearLinearToken();
366
+ setViewState({ type: 'linear-auth-input' });
367
+ return;
368
+ }
369
+ return;
370
+ }
371
+ // In ready state
372
+ if (viewState.type !== 'ready')
373
+ return;
374
+ // Tab to switch focus
77
375
  if (key.tab) {
78
376
  setFocusedPanel((prev) => (prev === 'list' ? 'details' : 'list'));
79
377
  return;
80
378
  }
81
379
  // Refresh issues
82
380
  if (input === 'r') {
83
- setLoading(true);
84
- setError(null);
85
- // Trigger re-fetch by updating a dependency
86
- const repos = getLocalRepos();
87
- const allIssues = [];
88
- for (const repo of repos) {
89
- const repoIssues = fetchGitHubIssues(repo.owner, repo.repo);
90
- for (const issue of repoIssues) {
91
- allIssues.push({
92
- ...issue,
93
- repoName: repo.name,
94
- fullRepo: `${repo.owner}/${repo.repo}`,
95
- });
96
- }
97
- }
98
- allIssues.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
99
- setIssues(allIssues);
100
- setSelectedIndex(0);
101
- setLoading(false);
381
+ loadIssues(currentFilter);
102
382
  return;
103
383
  }
104
- // Open in browser
105
- if (input === 'o' && selectedIssue) {
106
- openIssueInBrowser(selectedIssue.url);
384
+ // Open filter (Linear only)
385
+ if (input === 'f' && project.issueProvider?.type === 'linear') {
386
+ setViewState({ type: 'filter' });
387
+ setFilterField('assignee');
388
+ setFilterSelectedIndex(0);
107
389
  return;
108
390
  }
109
- // List navigation is handled by the List component via controlled selection
110
391
  });
111
- // Truncate title if too long
392
+ // Get filter options for current field
393
+ const getFilterOptions = (field) => {
394
+ switch (field) {
395
+ case 'assignee':
396
+ return [
397
+ { id: '__me__', label: 'Assigned to me' },
398
+ { id: '__any__', label: 'Anyone' },
399
+ { id: '__none__', label: 'Unassigned' },
400
+ ...assignees.map((a) => ({ id: a.id, label: a.name })),
401
+ ];
402
+ case 'cycle':
403
+ return [
404
+ { id: '__any__', label: 'Any cycle' },
405
+ ...cycles.map((c) => ({ id: c.id, label: c.name })),
406
+ ];
407
+ case 'status':
408
+ return [
409
+ { id: '__open__', label: 'Open (backlog, unstarted, started)' },
410
+ { id: '__all__', label: 'All statuses' },
411
+ { id: 'backlog', label: 'Backlog' },
412
+ { id: 'unstarted', label: 'Todo' },
413
+ { id: 'started', label: 'In Progress' },
414
+ { id: 'completed', label: 'Done' },
415
+ { id: 'canceled', label: 'Canceled' },
416
+ ];
417
+ case 'sort':
418
+ return [
419
+ { id: 'priority_desc', label: 'Priority (high first)' },
420
+ { id: 'priority_asc', label: 'Priority (low first)' },
421
+ { id: 'updatedAt', label: 'Recently updated' },
422
+ { id: 'createdAt', label: 'Recently created' },
423
+ ];
424
+ default:
425
+ return [];
426
+ }
427
+ };
428
+ // Select a filter option locally (without fetching) - used by Tab
429
+ // Returns the new filter for immediate use (since setState is async)
430
+ const selectFilterOptionLocal = (field, index) => {
431
+ const options = getFilterOptions(field);
432
+ const option = options[index];
433
+ if (!option)
434
+ return currentFilter;
435
+ const newFilter = { ...currentFilter };
436
+ switch (field) {
437
+ case 'assignee':
438
+ if (option.id === '__me__') {
439
+ // Only set if we have a valid user ID
440
+ if (currentUserId) {
441
+ newFilter.assigneeId = currentUserId;
442
+ }
443
+ setFilterByMe(true);
444
+ }
445
+ else if (option.id === '__any__') {
446
+ delete newFilter.assigneeId;
447
+ setFilterByMe(false);
448
+ }
449
+ else if (option.id === '__none__') {
450
+ newFilter.assigneeId = null;
451
+ setFilterByMe(false);
452
+ }
453
+ else {
454
+ newFilter.assigneeId = option.id;
455
+ setFilterByMe(false);
456
+ }
457
+ break;
458
+ case 'cycle':
459
+ if (option.id === '__any__') {
460
+ delete newFilter.cycleId;
461
+ }
462
+ else {
463
+ newFilter.cycleId = option.id;
464
+ }
465
+ break;
466
+ case 'status':
467
+ if (option.id === '__open__') {
468
+ newFilter.stateType = ['backlog', 'unstarted', 'started'];
469
+ }
470
+ else if (option.id === '__all__') {
471
+ delete newFilter.stateType;
472
+ }
473
+ else {
474
+ newFilter.stateType = option.id;
475
+ }
476
+ break;
477
+ case 'sort':
478
+ if (option.id === 'priority_desc') {
479
+ newFilter.sortBy = 'priority';
480
+ newFilter.sortDirection = 'desc';
481
+ }
482
+ else if (option.id === 'priority_asc') {
483
+ newFilter.sortBy = 'priority';
484
+ newFilter.sortDirection = 'asc';
485
+ }
486
+ else {
487
+ newFilter.sortBy = option.id;
488
+ delete newFilter.sortDirection;
489
+ }
490
+ break;
491
+ }
492
+ setCurrentFilter(newFilter);
493
+ return newFilter;
494
+ };
495
+ // Select a filter option and apply (fetch) - used by Enter
496
+ const selectFilterOption = async (field, index) => {
497
+ const options = getFilterOptions(field);
498
+ const option = options[index];
499
+ if (!option)
500
+ return;
501
+ const newFilter = { ...currentFilter };
502
+ switch (field) {
503
+ case 'assignee':
504
+ if (option.id === '__me__') {
505
+ // Use cached currentUserId or fetch it
506
+ const userId = currentUserId || await linearProvider?.getCurrentUserId();
507
+ if (userId) {
508
+ newFilter.assigneeId = userId;
509
+ setCurrentUserId(userId); // Cache it if we just fetched
510
+ }
511
+ setFilterByMe(true);
512
+ }
513
+ else if (option.id === '__any__') {
514
+ delete newFilter.assigneeId;
515
+ setFilterByMe(false);
516
+ }
517
+ else if (option.id === '__none__') {
518
+ newFilter.assigneeId = null;
519
+ setFilterByMe(false);
520
+ }
521
+ else {
522
+ newFilter.assigneeId = option.id;
523
+ setFilterByMe(false);
524
+ }
525
+ break;
526
+ case 'cycle':
527
+ if (option.id === '__any__') {
528
+ delete newFilter.cycleId;
529
+ }
530
+ else {
531
+ newFilter.cycleId = option.id;
532
+ }
533
+ break;
534
+ case 'status':
535
+ if (option.id === '__open__') {
536
+ newFilter.stateType = ['backlog', 'unstarted', 'started'];
537
+ }
538
+ else if (option.id === '__all__') {
539
+ delete newFilter.stateType;
540
+ }
541
+ else {
542
+ newFilter.stateType = option.id;
543
+ }
544
+ break;
545
+ case 'sort':
546
+ if (option.id === 'priority_desc') {
547
+ newFilter.sortBy = 'priority';
548
+ newFilter.sortDirection = 'desc';
549
+ }
550
+ else if (option.id === 'priority_asc') {
551
+ newFilter.sortBy = 'priority';
552
+ newFilter.sortDirection = 'asc';
553
+ }
554
+ else {
555
+ newFilter.sortBy = option.id;
556
+ delete newFilter.sortDirection;
557
+ }
558
+ break;
559
+ }
560
+ applyFilter(newFilter);
561
+ };
562
+ // Handle opening Claude for an issue (called from row action)
563
+ const handleOpenClaude = (issue) => {
564
+ if (!onOpenClaude)
565
+ return;
566
+ let workingDir;
567
+ if (issue.source.type === 'github') {
568
+ workingDir = issue.source.livestockPath || project.path;
569
+ }
570
+ else {
571
+ workingDir = project.path;
572
+ }
573
+ // Expand ~ to home directory and fallback to cwd if empty
574
+ if (workingDir) {
575
+ workingDir = expandPath(workingDir);
576
+ }
577
+ else {
578
+ workingDir = process.cwd();
579
+ }
580
+ const context = buildClaudeContext(issue);
581
+ onOpenClaude(workingDir, context);
582
+ };
112
583
  const truncate = (str, maxLen) => {
113
584
  if (str.length <= maxLen)
114
585
  return str;
115
586
  return str.slice(0, maxLen - 1) + '…';
116
587
  };
117
- // Build list items - put repo tag as prefix, truncate long titles
118
- const issueItems = issues.map((issue, i) => ({
119
- id: String(i),
120
- label: `[${issue.repoName}] #${issue.number} ${truncate(issue.title, 35)}`,
121
- status: issue.state === 'open' ? 'active' : 'inactive',
122
- }));
123
- // Build issue details markdown
588
+ // Build issue details with better layout
124
589
  const buildIssueDetails = (issue) => {
125
590
  const lines = [];
126
- lines.push(`**State:** ${issue.state}`);
127
- lines.push(`**Author:** @${issue.author}`);
128
- lines.push(`**Repo:** ${issue.fullRepo}`);
591
+ // Metadata grid
592
+ if (issue.source.type === 'github') {
593
+ lines.push(`**Repo:** ${issue.source.repo} • **Livestock:** ${issue.source.livestockName}`);
594
+ }
595
+ lines.push(`**State:** ${issue.state} • **Author:** ${issue.author}`);
596
+ if (issue.priority !== undefined && issue.priority > 0) {
597
+ const priorityNames = ['', 'Urgent', 'High', 'Medium', 'Low'];
598
+ const priorityLine = `**Priority:** ${priorityNames[issue.priority]}`;
599
+ const pointsLine = issue.estimate !== undefined ? ` • **Points:** ${issue.estimate}` : '';
600
+ lines.push(priorityLine + pointsLine);
601
+ }
602
+ else if (issue.estimate !== undefined) {
603
+ lines.push(`**Points:** ${issue.estimate}`);
604
+ }
605
+ if (issue.assignee) {
606
+ lines.push(`**Assignee:** ${issue.assignee.name}`);
607
+ }
608
+ if (issue.cycle) {
609
+ lines.push(`**Cycle:** ${issue.cycle.name}`);
610
+ }
129
611
  if (issue.labels.length > 0) {
130
612
  lines.push(`**Labels:** ${issue.labels.join(', ')}`);
131
613
  }
132
- lines.push(`**Comments:** ${issue.commentsCount}`);
133
614
  lines.push(`**Updated:** ${new Date(issue.updatedAt).toLocaleDateString()}`);
134
615
  lines.push('');
135
616
  lines.push('---');
@@ -140,18 +621,209 @@ export function IssuesView({ project, onBack }) {
140
621
  else {
141
622
  lines.push('*No description provided.*');
142
623
  }
624
+ // Add comments section
625
+ if (issue.comments.length > 0) {
626
+ lines.push('');
627
+ lines.push('---');
628
+ lines.push('');
629
+ lines.push(`**Comments (${issue.comments.length}):**`);
630
+ lines.push('');
631
+ for (const comment of issue.comments) {
632
+ const date = new Date(comment.createdAt).toLocaleDateString();
633
+ lines.push(`**${comment.author}** - ${date}`);
634
+ lines.push('');
635
+ lines.push(comment.body);
636
+ lines.push('');
637
+ lines.push('---');
638
+ lines.push('');
639
+ }
640
+ }
143
641
  return lines.join('\n');
144
642
  };
145
- // Panel-specific hints (page-level hotkeys like r/o are in BottomBar)
146
- const listHints = '';
147
- const detailsHints = 'j/k scroll';
643
+ // Get subtitle for header
644
+ const getSubtitle = () => {
645
+ if (project.issueProvider?.type === 'linear' && teamName) {
646
+ return `Issues • ${teamName}`;
647
+ }
648
+ return 'Issues';
649
+ };
148
650
  // Loading state
149
- if (loading) {
150
- return (_jsxs(Box, { flexDirection: "column", flexGrow: 1, children: [_jsx(Header, { text: project.name, subtitle: "Issues", color: project.color }), _jsx(Box, { padding: 2, children: _jsx(Text, { children: "Loading issues..." }) })] }));
651
+ if (viewState.type === 'loading') {
652
+ return (_jsxs(Box, { flexDirection: "column", flexGrow: 1, children: [_jsx(Header, { text: project.name, subtitle: getSubtitle(), color: project.color }), _jsx(Box, { padding: 2, children: _jsx(Text, { children: "Loading issues..." }) })] }));
653
+ }
654
+ // Disabled state
655
+ if (viewState.type === 'disabled') {
656
+ return (_jsxs(Box, { flexDirection: "column", flexGrow: 1, children: [_jsx(Header, { text: project.name, subtitle: "Issues", color: project.color }), _jsxs(Box, { padding: 2, flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "Issue tracking is disabled for this project." }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Press 'e' in the project view to enable it." }) })] })] }));
657
+ }
658
+ // Not authenticated - GitHub
659
+ if (viewState.type === 'not-authenticated' && viewState.providerType === 'github') {
660
+ return (_jsxs(Box, { flexDirection: "column", flexGrow: 1, children: [_jsx(Header, { text: project.name, subtitle: "Issues", color: project.color }), _jsxs(Box, { padding: 2, flexDirection: "column", children: [_jsx(Text, { color: "yellow", children: "GitHub CLI not authenticated" }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { children: "Run this command in your terminal:" }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "cyan", children: " gh auth login" }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Then return here and the issues will load." }) })] })] }));
661
+ }
662
+ // Not authenticated - Linear (prompt to enter key)
663
+ if (viewState.type === 'not-authenticated' && viewState.providerType === 'linear') {
664
+ return (_jsxs(Box, { flexDirection: "column", flexGrow: 1, children: [_jsx(Header, { text: project.name, subtitle: "Issues", color: project.color }), _jsxs(Box, { padding: 2, flexDirection: "column", children: [_jsx(Text, { color: "yellow", children: "Linear authentication required" }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { children: "You need a Personal API Key from Linear." }) }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { children: ["Create one at: ", _jsx(Text, { color: "cyan", children: LINEAR_API_KEY_URL })] }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Press Enter to paste your API key" }) })] })] }));
665
+ }
666
+ // Linear API key input
667
+ if (viewState.type === 'linear-auth-input') {
668
+ return (_jsxs(Box, { flexDirection: "column", flexGrow: 1, children: [_jsx(Header, { text: project.name, subtitle: "Issues", color: project.color }), _jsxs(Box, { padding: 2, flexDirection: "column", children: [_jsx(Text, { color: "yellow", children: "Enter Linear API Key" }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Paste your Personal API Key from Linear:" }) }), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { children: "API Key: " }), _jsx(TextInput, { value: apiKeyInput, onChange: setApiKeyInput, onSubmit: handleApiKeySubmit, mask: "*" })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Enter: submit, Esc: cancel" }) })] })] }));
669
+ }
670
+ // Linear validating
671
+ if (viewState.type === 'linear-auth-validating') {
672
+ return (_jsxs(Box, { flexDirection: "column", flexGrow: 1, children: [_jsx(Header, { text: project.name, subtitle: "Issues", color: project.color }), _jsx(Box, { padding: 2, flexDirection: "column", children: _jsx(Text, { children: "Validating API key..." }) })] }));
673
+ }
674
+ // Team selection
675
+ if (viewState.type === 'select-team') {
676
+ const teamItems = viewState.teams.map((team) => ({
677
+ id: team.id,
678
+ label: `${team.name} (${team.key})`,
679
+ status: 'active',
680
+ }));
681
+ return (_jsxs(Box, { flexDirection: "column", flexGrow: 1, children: [_jsx(Header, { text: project.name, subtitle: "Issues", color: project.color }), _jsxs(Box, { padding: 2, flexDirection: "column", children: [_jsx(Text, { bold: true, color: "yellow", children: "Select a Linear team" }), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { dimColor: true, children: "Which team's issues should be shown?" }) }), _jsx(List, { items: teamItems, focused: true, onSelect: (item) => {
682
+ const team = viewState.teams.find((t) => t.id === item.id);
683
+ if (team)
684
+ selectTeam(team);
685
+ } })] })] }));
686
+ }
687
+ // Filter view
688
+ if (viewState.type === 'filter') {
689
+ const fields = [
690
+ { key: 'assignee', label: 'Assignee' },
691
+ { key: 'cycle', label: 'Cycle' },
692
+ { key: 'status', label: 'Status' },
693
+ { key: 'sort', label: 'Sort by' },
694
+ ];
695
+ const currentOptions = getFilterOptions(filterField);
696
+ return (_jsxs(Box, { flexDirection: "column", flexGrow: 1, children: [_jsx(Header, { text: project.name, subtitle: getSubtitle(), color: project.color }), _jsxs(Box, { paddingX: 2, paddingY: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Filter Issues" }), _jsx(Box, { marginTop: 1, gap: 2, children: fields.map((f) => (_jsx(Text, { color: filterField === f.key ? 'cyan' : 'gray', children: filterField === f.key ? `[${f.label}]` : f.label }, f.key))) }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: currentOptions.map((opt, i) => (_jsxs(Text, { color: i === filterSelectedIndex ? 'green' : undefined, children: [i === filterSelectedIndex ? '▸ ' : ' ', opt.label] }, opt.id))) }), _jsx(Box, { marginTop: 2, children: _jsx(Text, { dimColor: true, children: "Tab: switch field \u2022 j/k: navigate \u2022 Enter: select \u2022 Esc: cancel" }) })] })] }));
151
697
  }
152
698
  // Error state
153
- if (error) {
154
- return (_jsxs(Box, { flexDirection: "column", flexGrow: 1, children: [_jsx(Header, { text: project.name, subtitle: "Issues", color: project.color }), _jsx(Box, { padding: 2, flexDirection: "column", children: _jsxs(Text, { color: "red", children: ["Error: ", error] }) })] }));
699
+ if (viewState.type === 'error') {
700
+ return (_jsxs(Box, { flexDirection: "column", flexGrow: 1, children: [_jsx(Header, { text: project.name, subtitle: getSubtitle(), color: project.color }), _jsxs(Box, { padding: 2, flexDirection: "column", children: [_jsxs(Text, { color: "red", children: ["Error: ", viewState.message] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Press 'r' to retry, 'a' to re-authenticate" }) })] })] }));
155
701
  }
156
- return (_jsxs(Box, { flexDirection: "column", flexGrow: 1, children: [_jsx(Header, { text: project.name, subtitle: "Issues", color: project.color }), _jsxs(Box, { flexGrow: 1, paddingX: 1, gap: 2, children: [_jsx(Panel, { title: "Issues", focused: focusedPanel === 'list', width: "40%", hints: listHints, children: issueItems.length > 0 ? (_jsx(List, { items: issueItems, focused: focusedPanel === 'list', selectedIndex: selectedIndex, onSelectionChange: setSelectedIndex })) : (_jsx(Box, { flexDirection: "column", children: _jsx(Text, { dimColor: true, children: "No open issues" }) })) }), _jsx(Panel, { title: selectedIssue ? `#${selectedIssue.number} ${selectedIssue.title}` : 'Details', focused: focusedPanel === 'details', width: "60%", hints: detailsHints, children: selectedIssue ? (_jsx(ScrollableMarkdown, { focused: focusedPanel === 'details', height: 20, children: buildIssueDetails(selectedIssue) })) : (_jsx(Text, { dimColor: true, children: "Select an issue to view details" })) })] })] }));
702
+ // Ready state - show issues
703
+ if (viewState.type !== 'ready') {
704
+ return null;
705
+ }
706
+ // Row-level actions for issues
707
+ const issueActions = onOpenClaude
708
+ ? [{ key: 'c', label: 'claude' }, { key: 'o', label: 'open' }]
709
+ : [{ key: 'o', label: 'open' }];
710
+ const isLinear = project.issueProvider?.type === 'linear';
711
+ const issueItems = viewState.issues.map((issue, i) => {
712
+ let label;
713
+ if (isLinear) {
714
+ // Linear: status indicator + priority + identifier + title + assignee + points
715
+ const status = getStatusIndicator(issue.stateType);
716
+ const priority = getPriorityIndicator(issue.priority);
717
+ const initials = getInitials(issue.assignee?.name);
718
+ const points = issue.estimate !== undefined ? `${issue.estimate}p` : '';
719
+ // Format: ▒! ENG-123 Issue title here CD 2p
720
+ const titleMaxLen = 32;
721
+ const truncatedTitle = truncate(issue.title, titleMaxLen);
722
+ label = `${priority} ${issue.identifier} ${truncatedTitle}`;
723
+ // Add assignee and points at the end
724
+ const suffix = [initials, points].filter(Boolean).join(' ');
725
+ if (suffix) {
726
+ // Pad to align
727
+ const padding = Math.max(0, 50 - label.length - suffix.length);
728
+ label = label + ' '.repeat(padding) + suffix;
729
+ }
730
+ return {
731
+ id: String(i),
732
+ label,
733
+ actions: issueActions,
734
+ prefix: _jsx(Text, { color: status.color, children: status.char }),
735
+ };
736
+ }
737
+ else {
738
+ // GitHub: [livestock] identifier title
739
+ const sourceTag = issue.source.type === 'github' ? issue.source.livestockName : '';
740
+ label = `[${sourceTag}] ${issue.identifier} ${truncate(issue.title, 35)}`;
741
+ return {
742
+ id: String(i),
743
+ label,
744
+ actions: issueActions,
745
+ };
746
+ }
747
+ });
748
+ // Build current filter description
749
+ const getFilterDescription = () => {
750
+ if (!isLinear)
751
+ return '';
752
+ const parts = [];
753
+ // Assignee - check explicitly for the different states
754
+ // assigneeId can be: string (specific user), null (unassigned), or undefined (anyone)
755
+ if (currentFilter.assigneeId === null) {
756
+ parts.push('Unassigned');
757
+ }
758
+ else if (filterByMe) {
759
+ // User explicitly selected "Me" filter
760
+ parts.push('Me');
761
+ }
762
+ else if (currentFilter.assigneeId !== undefined && currentFilter.assigneeId !== '') {
763
+ // Has a specific assignee ID (not "me")
764
+ const assignee = assignees.find((a) => a.id === currentFilter.assigneeId);
765
+ parts.push(assignee?.name ?? 'Assigned');
766
+ }
767
+ else {
768
+ // undefined or empty string means anyone
769
+ parts.push('Anyone');
770
+ }
771
+ // Cycle
772
+ if (currentFilter.cycleId) {
773
+ const cycle = cycles.find((c) => c.id === currentFilter.cycleId);
774
+ parts.push(cycle?.name ?? 'Cycle');
775
+ }
776
+ else {
777
+ parts.push('Any cycle');
778
+ }
779
+ // Status
780
+ if (currentFilter.stateType) {
781
+ const stateType = currentFilter.stateType;
782
+ if (Array.isArray(stateType)) {
783
+ // Check if it's the "open" preset
784
+ const isOpenPreset = stateType.length === 3 &&
785
+ stateType.includes('backlog') &&
786
+ stateType.includes('unstarted') &&
787
+ stateType.includes('started');
788
+ parts.push(isOpenPreset ? 'Open' : stateType.join(', '));
789
+ }
790
+ else {
791
+ // Single status
792
+ const statusLabels = {
793
+ backlog: 'Backlog',
794
+ unstarted: 'Todo',
795
+ started: 'In Progress',
796
+ completed: 'Done',
797
+ canceled: 'Canceled',
798
+ };
799
+ parts.push(statusLabels[stateType] ?? stateType);
800
+ }
801
+ }
802
+ else {
803
+ parts.push('All statuses');
804
+ }
805
+ // Sort
806
+ if (currentFilter.sortBy === 'priority') {
807
+ parts.push(currentFilter.sortDirection === 'asc' ? '↑Priority' : '↓Priority');
808
+ }
809
+ else if (currentFilter.sortBy === 'createdAt') {
810
+ parts.push('Recent');
811
+ }
812
+ else if (currentFilter.sortBy === 'updatedAt') {
813
+ parts.push('Updated');
814
+ }
815
+ return parts.join(' • ');
816
+ };
817
+ const detailsHints = 'j/k scroll';
818
+ return (_jsxs(Box, { flexDirection: "column", flexGrow: 1, children: [_jsx(Header, { text: project.name, subtitle: getSubtitle(), color: project.color }), isLinear && (_jsxs(Box, { paddingX: 2, children: [_jsx(Text, { dimColor: true, children: "Showing: " }), _jsx(Text, { color: "cyan", children: getFilterDescription() }), _jsxs(Text, { dimColor: true, children: [" (", viewState.issues.length, ")"] })] })), _jsxs(Box, { flexGrow: 1, flexShrink: 1, paddingX: 1, gap: 2, overflow: "hidden", children: [_jsx(Panel, { title: "Issues", focused: focusedPanel === 'list', width: "45%", children: issueItems.length > 0 ? (_jsx(List, { items: issueItems, focused: focusedPanel === 'list', selectedIndex: selectedIndex, onSelectionChange: setSelectedIndex, onAction: (item, actionKey) => {
819
+ const issue = viewState.issues[parseInt(item.id, 10)];
820
+ if (!issue)
821
+ return;
822
+ if (actionKey === 'c') {
823
+ handleOpenClaude(issue);
824
+ }
825
+ else if (actionKey === 'o') {
826
+ openIssueInBrowser(issue.url);
827
+ }
828
+ } })) : (_jsx(Text, { dimColor: true, children: "No issues match the current filter" })) }), _jsx(Panel, { title: selectedIssue ? `${selectedIssue.identifier} ${truncate(selectedIssue.title, 30)}` : 'Details', focused: focusedPanel === 'details', width: "55%", hints: detailsHints, children: selectedIssue ? (_jsx(ScrollableMarkdown, { focused: focusedPanel === 'details', height: panelContentHeight, children: buildIssueDetails(selectedIssue) })) : (_jsx(Text, { dimColor: true, children: "Select an issue to view details" })) })] })] }));
157
829
  }