@colmbus72/yeehaw 0.5.0 → 0.6.0

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