@colmbus72/yeehaw 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/app.js CHANGED
@@ -1,10 +1,11 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useState, useCallback } from 'react';
2
+ import { useState, useCallback, useMemo, useEffect } from 'react';
3
3
  import { Box, Text, useApp, useInput, useStdout } from 'ink';
4
4
  import { homedir } from 'os';
5
5
  import { join } from 'path';
6
6
  import { HelpOverlay } from './components/HelpOverlay.js';
7
7
  import { BottomBar } from './components/BottomBar.js';
8
+ import { SplashScreen } from './components/SplashScreen.js';
8
9
  import { GlobalDashboard } from './views/GlobalDashboard.js';
9
10
  import { ProjectContext } from './views/ProjectContext.js';
10
11
  import { BarnContext } from './views/BarnContext.js';
@@ -16,8 +17,9 @@ import { NightSkyView } from './views/NightSkyView.js';
16
17
  import { useConfig } from './hooks/useConfig.js';
17
18
  import { useSessions } from './hooks/useSessions.js';
18
19
  import { useRemoteYeehaw } from './hooks/useRemoteYeehaw.js';
19
- import { hasTmux, switchToWindow, updateStatusBar, createShellWindow, createSshWindow, detachFromSession, killYeehawSession, enterRemoteMode, } from './lib/tmux.js';
20
+ import { hasTmux, switchToWindow, updateStatusBar, createShellWindow, createSshWindow, detachFromSession, killYeehawSession, enterRemoteMode, ensureCorrectStatusBar, } from './lib/tmux.js';
20
21
  import { saveProject, deleteProject, saveBarn, deleteBarn, getLivestockForBarn, isLocalBarn, hasValidSshConfig } from './lib/config.js';
22
+ import { getVersionInfo } from './lib/update-check.js';
21
23
  function getHotkeyScope(view) {
22
24
  switch (view.type) {
23
25
  case 'global': return 'global-dashboard';
@@ -47,27 +49,27 @@ function getBottomBarItems(viewType) {
47
49
  return [...common, { key: 'c', label: 'claude' }, { key: 'q', label: 'detach' }, { key: 'Q', label: 'quit' }];
48
50
  }
49
51
  if (viewType === 'project') {
50
- return [...common, { key: 'c', label: 'claude' }, { key: 'w', label: 'wiki' }, { key: 'i', label: 'issues' }, { key: 'q', label: 'back' }];
52
+ return [...common, { key: 'c', label: 'claude' }, { key: 'w', label: 'wiki' }, { key: 'i', label: 'issues' }, { key: 'Esc', label: 'back' }];
51
53
  }
52
54
  if (viewType === 'barn') {
53
- return [...common, { key: 's', label: 'shell' }, { key: 'q', label: 'back' }];
55
+ return [...common, { key: 's', label: 'shell' }, { key: 'Esc', label: 'back' }];
54
56
  }
55
57
  if (viewType === 'wiki') {
56
- return [...common, { key: 'q', label: 'back' }];
58
+ return [...common, { key: 'Esc', label: 'back' }];
57
59
  }
58
60
  if (viewType === 'issues') {
59
- return [...common, { key: 'r', label: 'refresh' }, { key: 'o', label: 'open' }, { key: 'q', label: 'back' }];
61
+ return [...common, { key: 'r', label: 'refresh' }, { key: 'o', label: 'open' }, { key: 'Esc', label: 'back' }];
60
62
  }
61
63
  if (viewType === 'livestock') {
62
- return [...common, { key: 's', label: 'shell' }, { key: 'l', label: 'logs' }, { key: 'e', label: 'edit' }, { key: 'q', label: 'back' }];
64
+ return [...common, { key: 's', label: 'shell' }, { key: 'l', label: 'logs' }, { key: 'e', label: 'edit' }, { key: 'Esc', label: 'back' }];
63
65
  }
64
66
  if (viewType === 'logs') {
65
- return [...common, { key: 'r', label: 'refresh' }, { key: 'q', label: 'back' }];
67
+ return [...common, { key: 'r', label: 'refresh' }, { key: 'Esc', label: 'back' }];
66
68
  }
67
69
  if (viewType === 'night-sky') {
68
70
  return [{ key: 'c', label: 'cloud' }, { key: 'r', label: 'randomize' }, { key: 'Esc', label: 'exit' }];
69
71
  }
70
- return [...common, { key: 'q', label: 'back' }];
72
+ return [...common, { key: 'Esc', label: 'back' }];
71
73
  }
72
74
  export function App() {
73
75
  const { exit } = useApp();
@@ -75,6 +77,7 @@ export function App() {
75
77
  const { windows, createClaude, attachToWindow } = useSessions();
76
78
  const { stdout } = useStdout();
77
79
  const { environments, isDetecting } = useRemoteYeehaw(barns);
80
+ const [showSplash, setShowSplash] = useState(true);
78
81
  const [view, setView] = useState({ type: 'global' });
79
82
  const [previousView, setPreviousView] = useState(null);
80
83
  const [showHelp, setShowHelp] = useState(false);
@@ -84,6 +87,14 @@ export function App() {
84
87
  const terminalHeight = stdout?.rows || 24;
85
88
  // Check tmux availability
86
89
  const tmuxAvailable = hasTmux();
90
+ // Get version info (cached, so safe to call synchronously)
91
+ const versionInfo = useMemo(() => getVersionInfo(), []);
92
+ // Ensure status bar is hidden when on global dashboard
93
+ useEffect(() => {
94
+ if (view.type === 'global') {
95
+ ensureCorrectStatusBar();
96
+ }
97
+ }, [view.type]);
87
98
  const handleSelectProject = useCallback((project) => {
88
99
  setView({ type: 'project', project });
89
100
  updateStatusBar(project.name);
@@ -104,11 +115,17 @@ export function App() {
104
115
  setError('tmux is not installed');
105
116
  return;
106
117
  }
107
- const projectName = view.type === 'project' ? view.project.name : 'yeehaw';
108
- const workingDir = view.type === 'project' ? expandPath(view.project.path) : process.cwd();
109
- const windowName = `${projectName}-claude`;
110
- const windowIndex = createClaude(workingDir, windowName);
111
- switchToWindow(windowIndex);
118
+ try {
119
+ const projectName = view.type === 'project' ? view.project.name : 'yeehaw';
120
+ const workingDir = view.type === 'project' ? expandPath(view.project.path) : process.cwd();
121
+ const windowName = `${projectName}-claude`;
122
+ const windowIndex = createClaude(workingDir, windowName);
123
+ switchToWindow(windowIndex);
124
+ }
125
+ catch (err) {
126
+ const message = err instanceof Error ? err.message : String(err);
127
+ setError(`Failed to create Claude session: ${message}`);
128
+ }
112
129
  }, [tmuxAvailable, view, createClaude]);
113
130
  const handleOpenLivestockSession = useCallback((livestock, barn, projectName) => {
114
131
  if (!tmuxAvailable) {
@@ -164,27 +181,33 @@ export function App() {
164
181
  setView({ type: 'project', project });
165
182
  updateStatusBar(project.name);
166
183
  }, []);
167
- const handleOpenLivestockDetail = useCallback((project, livestock) => {
168
- setView({ type: 'livestock', project, livestock });
184
+ const handleOpenLivestockDetail = useCallback((project, livestock, source, sourceBarn) => {
185
+ setView({ type: 'livestock', project, livestock, source, sourceBarn });
169
186
  updateStatusBar(`${project.name} / ${livestock.name}`);
170
187
  }, []);
171
- const handleOpenLogs = useCallback((project, livestock) => {
172
- setView({ type: 'logs', project, livestock });
188
+ const handleOpenLogs = useCallback((project, livestock, source, sourceBarn) => {
189
+ setView({ type: 'logs', project, livestock, source, sourceBarn });
173
190
  updateStatusBar(`${project.name} / ${livestock.name} Logs`);
174
191
  }, []);
175
- const handleBackFromLivestock = useCallback((project) => {
176
- setView({ type: 'project', project });
177
- updateStatusBar(project.name);
192
+ const handleBackFromLivestock = useCallback((source, project, sourceBarn) => {
193
+ if (source === 'barn' && sourceBarn) {
194
+ setView({ type: 'barn', barn: sourceBarn });
195
+ updateStatusBar(`Barn: ${sourceBarn.name}`);
196
+ }
197
+ else {
198
+ setView({ type: 'project', project });
199
+ updateStatusBar(project.name);
200
+ }
178
201
  }, []);
179
- const handleUpdateLivestock = useCallback((project, updatedLivestock) => {
202
+ const handleUpdateLivestock = useCallback((project, originalLivestock, updatedLivestock, source, sourceBarn) => {
180
203
  const updatedProject = {
181
204
  ...project,
182
- livestock: (project.livestock || []).map((l) => l.name === updatedLivestock.name ? updatedLivestock : l),
205
+ livestock: (project.livestock || []).map((l) => l.name === originalLivestock.name ? updatedLivestock : l),
183
206
  };
184
207
  saveProject(updatedProject);
185
208
  reload();
186
- // Update the view with the new livestock data
187
- setView({ type: 'livestock', project: updatedProject, livestock: updatedLivestock });
209
+ // Update the view with the new livestock data, preserving navigation context
210
+ setView({ type: 'livestock', project: updatedProject, livestock: updatedLivestock, source, sourceBarn });
188
211
  }, [reload]);
189
212
  const handleDeleteProject = useCallback((projectName) => {
190
213
  deleteProject(projectName);
@@ -288,46 +311,29 @@ export function App() {
288
311
  setShowHelp(false);
289
312
  return;
290
313
  }
291
- // Global shortcuts
292
- if (input === 'q') {
293
- if (view.type === 'wiki' || view.type === 'issues') {
294
- handleBackFromSubview(view.project);
295
- }
296
- else if (view.type === 'logs') {
297
- // Back to livestock detail
298
- setView({ type: 'livestock', project: view.project, livestock: view.livestock });
299
- updateStatusBar(`${view.project.name} / ${view.livestock.name}`);
300
- }
301
- else if (view.type === 'livestock') {
302
- handleBackFromLivestock(view.project);
303
- }
304
- else if (view.type === 'project' || view.type === 'barn') {
305
- handleBack();
306
- }
307
- else {
308
- // Detach from session (keeps yeehaw running in background)
309
- // Don't call exit() - let the TUI keep running so reattach works
310
- detachFromSession();
311
- }
314
+ // q: Detach from session (only on global dashboard)
315
+ if (input === 'q' && view.type === 'global') {
316
+ detachFromSession();
312
317
  return;
313
318
  }
314
- // Shift-Q: Kill everything
319
+ // Shift-Q: Kill everything (only on global dashboard)
315
320
  if (input === 'Q' && view.type === 'global') {
316
321
  killYeehawSession();
317
322
  exit();
318
323
  return;
319
324
  }
325
+ // ESC: Navigate back (handled by individual views for their sub-modes,
326
+ // but also handled here as a fallback for consistent navigation)
320
327
  if (key.escape) {
321
328
  if (view.type === 'wiki' || view.type === 'issues') {
322
329
  handleBackFromSubview(view.project);
323
330
  }
324
331
  else if (view.type === 'logs') {
325
- // Back to livestock detail
326
- setView({ type: 'livestock', project: view.project, livestock: view.livestock });
332
+ setView({ type: 'livestock', project: view.project, livestock: view.livestock, source: view.source, sourceBarn: view.sourceBarn });
327
333
  updateStatusBar(`${view.project.name} / ${view.livestock.name}`);
328
334
  }
329
335
  else if (view.type === 'livestock') {
330
- handleBackFromLivestock(view.project);
336
+ handleBackFromLivestock(view.source, view.project, view.sourceBarn);
331
337
  }
332
338
  else if (view.type === 'project' || view.type === 'barn') {
333
339
  handleBack();
@@ -364,12 +370,12 @@ export function App() {
364
370
  }
365
371
  switch (view.type) {
366
372
  case 'global':
367
- return (_jsx(GlobalDashboard, { projects: projects, barns: barns, windows: windows, onSelectProject: handleSelectProject, onSelectBarn: handleSelectBarn, onSelectWindow: handleSelectWindow, onNewClaude: handleNewClaude, onCreateProject: handleCreateProject, onCreateBarn: handleCreateBarn, onSshToBarn: handleSshToBarn }));
373
+ return (_jsx(GlobalDashboard, { projects: projects, barns: barns, windows: windows, versionInfo: versionInfo, onSelectProject: handleSelectProject, onSelectBarn: handleSelectBarn, onSelectWindow: handleSelectWindow, onNewClaude: handleNewClaude, onCreateProject: handleCreateProject, onCreateBarn: handleCreateBarn, onSshToBarn: handleSshToBarn }));
368
374
  case 'project':
369
- return (_jsx(ProjectContext, { project: view.project, barns: barns, windows: windows, onBack: handleBack, onNewClaude: handleNewClaude, onSelectWindow: handleSelectWindow, onSelectLivestock: (livestock, barn) => handleOpenLivestockDetail(view.project, livestock), onOpenLivestockSession: (livestock, barn) => handleOpenLivestockSession(livestock, barn, view.project.name), onUpdateProject: handleUpdateProject, onDeleteProject: handleDeleteProject, onOpenWiki: () => handleOpenWiki(view.project), onOpenIssues: () => handleOpenIssues(view.project) }));
375
+ return (_jsx(ProjectContext, { project: view.project, barns: barns, windows: windows, onBack: handleBack, onNewClaude: handleNewClaude, onSelectWindow: handleSelectWindow, onSelectLivestock: (livestock, barn) => handleOpenLivestockDetail(view.project, livestock, 'project'), onOpenLivestockSession: (livestock, barn) => handleOpenLivestockSession(livestock, barn, view.project.name), onUpdateProject: handleUpdateProject, onDeleteProject: handleDeleteProject, onOpenWiki: () => handleOpenWiki(view.project), onOpenIssues: () => handleOpenIssues(view.project) }));
370
376
  case 'barn':
371
377
  const barnLivestock = getLivestockForBarn(view.barn.name);
372
- return (_jsx(BarnContext, { barn: view.barn, livestock: barnLivestock, projects: projects, windows: windows, onBack: handleBack, onSshToBarn: () => handleSshToBarn(view.barn), onSelectLivestock: (project, livestock) => handleOpenLivestockDetail(project, livestock), onOpenLivestockSession: (project, livestock) => {
378
+ return (_jsx(BarnContext, { barn: view.barn, livestock: barnLivestock, projects: projects, windows: windows, onBack: handleBack, onSshToBarn: () => handleSshToBarn(view.barn), onSelectLivestock: (project, livestock) => handleOpenLivestockDetail(project, livestock, 'barn', view.barn), onOpenLivestockSession: (project, livestock) => {
373
379
  const barn = barns.find((b) => b.name === livestock.barn) || null;
374
380
  handleOpenLivestockSession(livestock, barn, project.name);
375
381
  }, onUpdateBarn: handleUpdateBarn, onDeleteBarn: handleDeleteBarn, onAddLivestock: (project, livestock) => {
@@ -397,18 +403,22 @@ export function App() {
397
403
  case 'issues':
398
404
  return (_jsx(IssuesView, { project: view.project, onBack: () => handleBackFromSubview(view.project) }));
399
405
  case 'livestock':
400
- return (_jsx(LivestockDetailView, { project: view.project, livestock: view.livestock, onBack: () => handleBackFromLivestock(view.project), onOpenLogs: () => handleOpenLogs(view.project, view.livestock), onOpenSession: () => {
406
+ return (_jsx(LivestockDetailView, { project: view.project, livestock: view.livestock, source: view.source, sourceBarn: view.sourceBarn, windows: windows, onBack: () => handleBackFromLivestock(view.source, view.project, view.sourceBarn), onOpenLogs: () => handleOpenLogs(view.project, view.livestock, view.source, view.sourceBarn), onOpenSession: () => {
401
407
  const barn = barns.find((b) => b.name === view.livestock.barn) || null;
402
408
  handleOpenLivestockSession(view.livestock, barn, view.project.name);
403
- }, onUpdateLivestock: (updatedLivestock) => handleUpdateLivestock(view.project, updatedLivestock) }));
409
+ }, onSelectWindow: handleSelectWindow, onUpdateLivestock: (originalLivestock, updatedLivestock) => handleUpdateLivestock(view.project, originalLivestock, updatedLivestock, view.source, view.sourceBarn) }));
404
410
  case 'logs':
405
411
  return (_jsx(LogsView, { project: view.project, livestock: view.livestock, onBack: () => {
406
- setView({ type: 'livestock', project: view.project, livestock: view.livestock });
412
+ setView({ type: 'livestock', project: view.project, livestock: view.livestock, source: view.source, sourceBarn: view.sourceBarn });
407
413
  updateStatusBar(`${view.project.name} / ${view.livestock.name}`);
408
414
  } }));
409
415
  case 'night-sky':
410
416
  return (_jsx(NightSkyView, { onExit: handleExitNightSky }));
411
417
  }
412
418
  };
419
+ // Show splash screen on first load
420
+ if (showSplash) {
421
+ return _jsx(SplashScreen, { onComplete: () => setShowSplash(false) });
422
+ }
413
423
  return (_jsxs(Box, { flexDirection: "column", height: terminalHeight, children: [error && (_jsx(Box, { paddingX: 1, children: _jsxs(Text, { color: "red", children: ["Error: ", error] }) })), _jsx(Box, { flexDirection: "column", flexGrow: 1, children: renderView() }), !showHelp && (_jsx(BottomBar, { items: getBottomBarItems(view.type), environments: environments, isDetecting: isDetecting }))] }));
414
424
  }
@@ -3,6 +3,13 @@ interface HeaderProps {
3
3
  subtitle?: string;
4
4
  summary?: string;
5
5
  color?: string;
6
+ gradientSpread?: number;
7
+ gradientInverted?: boolean;
8
+ theme?: 'dark' | 'light';
9
+ versionInfo?: {
10
+ current: string;
11
+ latest: string | null;
12
+ };
6
13
  }
7
- export declare function Header({ text, subtitle, summary, color }: HeaderProps): import("react/jsx-runtime").JSX.Element;
14
+ export declare function Header({ text, subtitle, summary, color, gradientSpread, gradientInverted, theme, versionInfo }: HeaderProps): import("react/jsx-runtime").JSX.Element;
8
15
  export {};
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useEffect, useState } from 'react';
2
+ import { useMemo } from 'react';
3
3
  import { Box, Text } from 'ink';
4
4
  import figlet from 'figlet';
5
5
  // Convert hex to RGB
@@ -21,34 +21,43 @@ function interpolateColor(color1, color2, factor) {
21
21
  return `rgb(${r},${g},${b})`;
22
22
  }
23
23
  // Generate gradient colors for each line
24
- function generateGradient(lines, baseColor) {
24
+ function generateGradient(lines, baseColor, spread = 5, inverted = false, theme = 'dark') {
25
25
  const rgb = hexToRgb(baseColor);
26
26
  if (!rgb)
27
27
  return lines.map(() => baseColor);
28
+ // Spread controls how much the gradient changes (0 = no change, 10 = max change)
29
+ // Convert 0-10 scale to a multiplier (0 = 1.0, 10 = 0.1 for darkening factor)
30
+ const spreadFactor = 1 - (spread / 10) * 0.9; // 0->1.0, 5->0.55, 10->0.1
28
31
  // Calculate luminance to detect dark colors
29
32
  const luminance = (0.299 * rgb.r + 0.587 * rgb.g + 0.114 * rgb.b) / 255;
30
33
  let startRgb;
31
34
  let endRgb;
32
- if (luminance < 0.3) {
33
- // Dark color: go from a lighter tint down to the base color
34
- // Lift toward white while preserving hue
35
- const liftFactor = 2.5;
35
+ // Determine gradient direction based on color luminance and theme
36
+ const isDarkColor = luminance < 0.3;
37
+ const shouldLighten = isDarkColor || theme === 'light';
38
+ if (shouldLighten) {
39
+ // Go from a lighter tint down to the base color (or adjusted end)
40
+ const liftFactor = 1 + (spread / 10) * 2; // More spread = more lift
36
41
  startRgb = {
37
- r: Math.min(255, Math.round(rgb.r * liftFactor + 40)),
38
- g: Math.min(255, Math.round(rgb.g * liftFactor + 40)),
39
- b: Math.min(255, Math.round(rgb.b * liftFactor + 40)),
42
+ r: Math.min(255, Math.round(rgb.r * liftFactor + spread * 8)),
43
+ g: Math.min(255, Math.round(rgb.g * liftFactor + spread * 8)),
44
+ b: Math.min(255, Math.round(rgb.b * liftFactor + spread * 8)),
40
45
  };
41
46
  endRgb = rgb;
42
47
  }
43
48
  else {
44
- // Light/medium color: go from base to a darker version
49
+ // Go from base to a darker version
45
50
  startRgb = rgb;
46
51
  endRgb = {
47
- r: Math.round(rgb.r * 0.3),
48
- g: Math.round(rgb.g * 0.3),
49
- b: Math.round(rgb.b * 0.3),
52
+ r: Math.round(rgb.r * spreadFactor),
53
+ g: Math.round(rgb.g * spreadFactor),
54
+ b: Math.round(rgb.b * spreadFactor),
50
55
  };
51
56
  }
57
+ // Invert if requested
58
+ if (inverted) {
59
+ [startRgb, endRgb] = [endRgb, startRgb];
60
+ }
52
61
  return lines.map((_, i) => {
53
62
  const factor = i / Math.max(lines.length - 1, 1);
54
63
  return interpolateColor(startRgb, endRgb, factor);
@@ -63,21 +72,22 @@ const TUMBLEWEED = [
63
72
  ];
64
73
  // Brownish tan color to complement yeehaw gold
65
74
  const TUMBLEWEED_COLOR = '#b8860b';
66
- export function Header({ text, subtitle, summary, color }) {
67
- const [ascii, setAscii] = useState('');
68
- useEffect(() => {
69
- figlet.text(text.toUpperCase(), { font: 'ANSI Shadow' }, (err, result) => {
70
- if (!err && result) {
71
- setAscii(result);
72
- }
73
- });
75
+ export function Header({ text, subtitle, summary, color, gradientSpread, gradientInverted, theme, versionInfo }) {
76
+ // Use sync figlet to avoid flash on initial render
77
+ const ascii = useMemo(() => {
78
+ try {
79
+ return figlet.textSync(text.toUpperCase(), { font: 'ANSI Shadow' });
80
+ }
81
+ catch {
82
+ return text.toUpperCase();
83
+ }
74
84
  }, [text]);
75
85
  const lines = ascii.split('\n').filter(line => line.trim() !== '');
76
86
  const baseColor = color || '#f0c040'; // Default yeehaw gold
77
- const gradientColors = generateGradient(lines, baseColor);
87
+ const gradientColors = generateGradient(lines, baseColor, gradientSpread ?? 5, gradientInverted ?? false, theme ?? 'dark');
78
88
  // Show tumbleweed only for the main "yeehaw" title
79
89
  const showTumbleweed = text.toLowerCase() === 'yeehaw';
80
90
  // Vertically center tumbleweed next to ASCII art
81
91
  const tumbleweedTopPadding = Math.max(0, Math.floor((lines.length - TUMBLEWEED.length) / 2));
82
- return (_jsxs(Box, { flexDirection: "column", paddingTop: 1, paddingLeft: 2, children: [_jsxs(Box, { flexDirection: "row", children: [showTumbleweed && (_jsxs(Box, { flexDirection: "column", marginRight: 2, children: [Array(tumbleweedTopPadding).fill(null).map((_, i) => (_jsx(Text, { children: " " }, `pad-${i}`))), TUMBLEWEED.map((line, i) => (_jsx(Text, { color: TUMBLEWEED_COLOR, children: line }, `tumbleweed-${i}`)))] })), _jsx(Box, { flexDirection: "column", children: lines.map((line, i) => (_jsx(Text, { color: gradientColors[i], children: line }, i))) })] }), (subtitle || summary) && (_jsxs(Box, { gap: 2, children: [subtitle && _jsx(Text, { dimColor: true, children: subtitle }), summary && _jsxs(Text, { color: "gray", children: ["- ", summary] })] }))] }));
92
+ return (_jsxs(Box, { flexDirection: "column", paddingTop: 1, paddingLeft: 2, children: [_jsxs(Box, { flexDirection: "row", children: [showTumbleweed && (_jsxs(Box, { flexDirection: "column", marginRight: 2, children: [Array(tumbleweedTopPadding).fill(null).map((_, i) => (_jsx(Text, { children: " " }, `pad-${i}`))), TUMBLEWEED.map((line, i) => (_jsx(Text, { color: TUMBLEWEED_COLOR, bold: true, children: line }, `tumbleweed-${i}`)))] })), _jsx(Box, { flexDirection: "column", children: lines.map((line, i) => (_jsx(Text, { color: gradientColors[i], children: line }, i))) }), versionInfo && showTumbleweed && (_jsx(Box, { marginLeft: 2, paddingRight: 1, alignItems: "flex-end", flexGrow: 1, justifyContent: "flex-end", children: versionInfo.latest && versionInfo.latest !== versionInfo.current ? (_jsxs(Text, { children: [_jsxs(Text, { dimColor: true, children: ["v", versionInfo.current] }), _jsx(Text, { dimColor: true, children: " \u2192 " }), _jsxs(Text, { color: "yellow", children: ["v", versionInfo.latest] })] })) : versionInfo.latest ? (_jsxs(Text, { children: [_jsxs(Text, { dimColor: true, children: ["v", versionInfo.current] }), _jsx(Text, { color: "green", children: " \u2713 latest" })] })) : (_jsxs(Text, { dimColor: true, children: ["v", versionInfo.current] })) }))] }), (subtitle || summary) && (_jsxs(Box, { gap: 2, children: [subtitle && _jsx(Text, { dimColor: true, children: subtitle }), summary && _jsxs(Text, { color: "gray", children: ["- ", summary] })] }))] }));
83
93
  }
@@ -0,0 +1,7 @@
1
+ import type { Project, Livestock } from '../types.js';
2
+ interface LivestockHeaderProps {
3
+ project: Project;
4
+ livestock: Livestock;
5
+ }
6
+ export declare function LivestockHeader({ project, livestock }: LivestockHeaderProps): import("react/jsx-runtime").JSX.Element;
7
+ export {};
@@ -0,0 +1,122 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ // Base cow ASCII art with @ symbols as pattern spots
4
+ // The @ symbols will be replaced with pattern characters for variation
5
+ // const COW_TEMPLATE = [
6
+ // " ___,,,___ _..............._",
7
+ // " '-/~'~\-'` @@@@@' .@@ \\",
8
+ // " |a a | '@@@' `@@ ||",
9
+ // " | /@@. @@@. .@@@. @@||",
10
+ // " (oo/@@' .@@@@..@@@@@@. /|",
11
+ // " `` '._ /@@@ @@@@' <`\/",
12
+ // " | /`--....-'`Y \))",
13
+ // " ||| //'. |((",
14
+ // " ||| // ||",
15
+ // " //( ` /(",
16
+ // ];
17
+ const COW_TEMPLATE = [
18
+ " /; ;\\",
19
+ " __ \\\\____//",
20
+ " /{_\\_/ `'\\____",
21
+ " \\___ (o) (o }",
22
+ " _______________________/ :--'",
23
+ " ,-,'`@@@@@@@@@@@@@@@@@@@@@@ \\_ `__\\",
24
+ " ;:( @@@@@@@@@@@@@@@@@@@@@@@@ \\___(o'o)",
25
+ " :: ) @@@@@@@@@@@@@@@@@@@@@@@,'@@( `===='",
26
+ " :: \\ @@@@@@: @@@@@@@) @@ ( '@@@'",
27
+ " ;; /\\ @@@ /`, @@@@@\\ :@@@@@)",
28
+ " ::/ ) {_----------: :~`,~~;",
29
+ " ;;'`; : ) : / `; ;",
30
+ "`'`' / : : : : : :",
31
+ " )_ \\__; :_ ; \\_\\",
32
+ " :__\\ \\ \\ \\ : \\",
33
+ " `^' `^' `-^-'",
34
+ ];
35
+ // " /; ;\\",
36
+ // " __ \\\\____//",
37
+ // " /{_\\_/ `'\\____",
38
+ // " \\___ (o) (o }",
39
+ // " _____________________________/ :--'",
40
+ // ",-,'`@@@@@@@@@@@@@@@@@@@@@ \\_ `__\\",
41
+ // ";:( @@@@@@@@@@@@@@@@ @@@ @@@@@@@ \\___(o'o)",
42
+ // ":: ) @@@@@@@@@@@ @@@@@@@@@@@@@ ,'@@( `===='",
43
+ // ":: : @@@@@: @@@@@@@ @@@@ @@@@@@@ `@@@:",
44
+ // ":: \\ @@@@@: @@@@@@@) ( '@@@'",
45
+ // ";; /\\ /`, @@@@@@@@@\\ :@@@@@)",
46
+ // "::/ ) {_----------------: :~`,~~;",
47
+ // ";;'`; : ) : / `; ;",
48
+ // ";;;; : : ; : ; ; :",
49
+ // "`'`' / : : : : : :",
50
+ // " )_ \\__; :_ ; \\_\\ `,','",
51
+ // " :__\\ \\ \\ \\ : \\ * 8`;'* *",
52
+ // " `^' `^' `-^-' \\v/ : \\/",
53
+ //];
54
+ // Generate multiple hash values from a string for better distribution
55
+ function multiHash(str) {
56
+ const hashes = [];
57
+ // First hash - djb2
58
+ let hash1 = 5381;
59
+ for (let i = 0; i < str.length; i++) {
60
+ hash1 = ((hash1 << 5) + hash1) ^ str.charCodeAt(i);
61
+ }
62
+ hashes.push(Math.abs(hash1));
63
+ // Second hash - sdbm
64
+ let hash2 = 0;
65
+ for (let i = 0; i < str.length; i++) {
66
+ hash2 = str.charCodeAt(i) + (hash2 << 6) + (hash2 << 16) - hash2;
67
+ }
68
+ hashes.push(Math.abs(hash2));
69
+ // Third hash - fnv-1a inspired
70
+ let hash3 = 2166136261;
71
+ for (let i = 0; i < str.length; i++) {
72
+ hash3 ^= str.charCodeAt(i);
73
+ hash3 = (hash3 * 16777619) >>> 0;
74
+ }
75
+ hashes.push(hash3);
76
+ return hashes;
77
+ }
78
+ // Pattern characters - space and block characters only
79
+ // Space creates gaps in the spots, blocks create varying density
80
+ const PATTERN_CHARS = [' ', ' ', '░', '░', '▒', '▓', '█'];
81
+ // Generate pattern variation for the cow based on livestock/project data
82
+ function generateCowArt(livestock, project) {
83
+ // Use git branch (environment) + livestock name for unique seed
84
+ // Branch typically indicates environment (main, staging, production, etc.)
85
+ const seed = `${livestock.branch || 'default'}-${livestock.name}-${project.name}`;
86
+ const hashes = multiHash(seed);
87
+ // Replace @ symbols with pattern characters
88
+ // Use multiple hashes and position for maximum variety
89
+ let charIndex = 0;
90
+ const cowArt = COW_TEMPLATE.map((line, lineIndex) => {
91
+ let result = '';
92
+ for (const char of line) {
93
+ if (char === '@') {
94
+ // Mix multiple hashes with position data for variety
95
+ const h1 = hashes[0];
96
+ const h2 = hashes[1];
97
+ const h3 = hashes[2];
98
+ // Combine hashes with position in different ways
99
+ const mix = (h1 >> (charIndex % 17)) ^
100
+ (h2 >> ((charIndex + lineIndex) % 13)) ^
101
+ (h3 >> ((charIndex * 7 + lineIndex * 3) % 19));
102
+ const charChoice = Math.abs(mix) % PATTERN_CHARS.length;
103
+ result += PATTERN_CHARS[charChoice];
104
+ charIndex++;
105
+ }
106
+ else {
107
+ result += char;
108
+ }
109
+ }
110
+ return result;
111
+ });
112
+ return cowArt;
113
+ }
114
+ export function LivestockHeader({ project, livestock }) {
115
+ const cowArt = generateCowArt(livestock, project);
116
+ const color = project.color || '#b8860b'; // Use project color or default tan
117
+ // Calculate where to place the livestock name (in the body area)
118
+ // We'll show it to the right of the cow
119
+ const cowHeight = cowArt.length;
120
+ const infoStartLine = Math.floor(cowHeight / 2) - 1;
121
+ return (_jsx(Box, { flexDirection: "column", paddingTop: 1, paddingLeft: 1, children: _jsxs(Box, { flexDirection: "row", children: [_jsx(Box, { flexDirection: "column", children: cowArt.map((line, i) => (_jsx(Text, { color: color, bold: true, children: line }, i))) }), _jsxs(Box, { flexDirection: "column", marginLeft: 2, justifyContent: "center", children: [_jsx(Text, { bold: true, color: color, children: livestock.name }), _jsxs(Text, { dimColor: true, children: ["project: ", project.name] }), _jsxs(Text, { dimColor: true, children: ["barn: ", livestock.barn || 'local'] }), livestock.branch && (_jsxs(Text, { dimColor: true, children: ["branch: ", livestock.branch] }))] })] }) }));
122
+ }
@@ -1,11 +1,16 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useState, useEffect } from 'react';
2
+ import { useState, useEffect, useRef } from 'react';
3
3
  import { Box, Text, useInput } from 'ink';
4
4
  import { readdirSync, existsSync } from 'fs';
5
5
  import { join, dirname, basename } from 'path';
6
6
  import { homedir } from 'os';
7
7
  import { execaSync } from 'execa';
8
8
  import { hasValidSshConfig } from '../lib/config.js';
9
+ // Cache for remote directory listings: Map<"barnName:dirPath", string[]>
10
+ const remoteCompletionCache = new Map();
11
+ function getCacheKey(barnName, dirPath) {
12
+ return `${barnName}:${dirPath}`;
13
+ }
9
14
  function expandPath(path) {
10
15
  if (path.startsWith('~/')) {
11
16
  return join(homedir(), path.slice(2));
@@ -31,26 +36,22 @@ function getLocalCompletions(partialPath) {
31
36
  return [];
32
37
  }
33
38
  }
34
- function getRemoteCompletions(partialPath, barn) {
35
- if (!partialPath)
36
- return [];
37
- // Verify barn has valid SSH config
39
+ function fetchRemoteDirectoryListing(dir, barn) {
38
40
  if (!hasValidSshConfig(barn)) {
39
- return []; // Can't do remote completion without SSH config
41
+ return [];
40
42
  }
41
- // Handle ~ expansion for display
42
- const dir = partialPath.endsWith('/') ? partialPath : dirname(partialPath) || '~';
43
- const prefix = partialPath.endsWith('/') ? '' : basename(partialPath);
44
43
  try {
45
- // Use SSH to list directories on the remote server
44
+ // Fetch ALL directories in this directory (not filtered by prefix)
45
+ // This allows us to cache the full listing and filter client-side
46
46
  const result = execaSync('ssh', [
47
47
  '-p', String(barn.port),
48
48
  '-i', barn.identity_file,
49
49
  '-o', 'BatchMode=yes',
50
- '-o', 'ConnectTimeout=5',
50
+ '-o', 'ConnectTimeout=3',
51
+ '-o', 'StrictHostKeyChecking=accept-new',
51
52
  `${barn.user}@${barn.host}`,
52
- `ls -1d ${dir}/${prefix}*/ 2>/dev/null | xargs -n1 basename 2>/dev/null || true`
53
- ], { timeout: 10000 });
53
+ `ls -1F ${dir} 2>/dev/null | grep '/$' | sed 's|/$||' || true`
54
+ ], { timeout: 5000 });
54
55
  const output = result.stdout.trim();
55
56
  if (!output)
56
57
  return [];
@@ -60,21 +61,78 @@ function getRemoteCompletions(partialPath, barn) {
60
61
  return [];
61
62
  }
62
63
  }
64
+ function getRemoteCompletions(partialPath, barn) {
65
+ if (!partialPath)
66
+ return [];
67
+ // Verify barn has valid SSH config
68
+ if (!hasValidSshConfig(barn)) {
69
+ return [];
70
+ }
71
+ // Handle ~ expansion for display
72
+ const dir = partialPath.endsWith('/') ? partialPath.slice(0, -1) || '~' : dirname(partialPath) || '~';
73
+ const prefix = partialPath.endsWith('/') ? '' : basename(partialPath);
74
+ const cacheKey = getCacheKey(barn.name, dir);
75
+ // Check cache first
76
+ let allDirs = remoteCompletionCache.get(cacheKey);
77
+ if (!allDirs) {
78
+ // Fetch and cache
79
+ allDirs = fetchRemoteDirectoryListing(dir, barn);
80
+ remoteCompletionCache.set(cacheKey, allDirs);
81
+ }
82
+ // Filter by prefix
83
+ if (prefix) {
84
+ return allDirs.filter((d) => d.startsWith(prefix));
85
+ }
86
+ return allDirs;
87
+ }
88
+ // Pre-fetch a directory's contents in the background
89
+ function prefetchRemoteDirectory(dir, barn) {
90
+ if (!hasValidSshConfig(barn))
91
+ return;
92
+ const cacheKey = getCacheKey(barn.name, dir);
93
+ if (remoteCompletionCache.has(cacheKey))
94
+ return; // Already cached
95
+ // Fetch asynchronously (don't block)
96
+ setTimeout(() => {
97
+ const dirs = fetchRemoteDirectoryListing(dir, barn);
98
+ remoteCompletionCache.set(cacheKey, dirs);
99
+ }, 0);
100
+ }
63
101
  export function PathInput({ value, onChange, onSubmit, barn }) {
64
102
  const [cursorPos, setCursorPos] = useState(value.length);
65
103
  const [completions, setCompletions] = useState([]);
66
104
  const [loading, setLoading] = useState(false);
67
- // Update completions when value changes (debounced for remote)
105
+ const pendingFetch = useRef(null);
106
+ // Update completions when value changes
68
107
  useEffect(() => {
69
108
  if (barn) {
70
- // Remote completion - debounce
71
- setLoading(true);
72
- const timer = setTimeout(() => {
109
+ // Remote completion - check cache first
110
+ const dir = value.endsWith('/') ? value.slice(0, -1) || '~' : dirname(value) || '~';
111
+ const cacheKey = getCacheKey(barn.name, dir);
112
+ const cached = remoteCompletionCache.has(cacheKey);
113
+ if (cached) {
114
+ // Use cached data immediately
73
115
  const results = getRemoteCompletions(value, barn);
74
116
  setCompletions(results);
75
117
  setLoading(false);
76
- }, 300);
77
- return () => clearTimeout(timer);
118
+ }
119
+ else {
120
+ // Not cached - debounce the fetch
121
+ setLoading(true);
122
+ if (pendingFetch.current) {
123
+ clearTimeout(pendingFetch.current);
124
+ }
125
+ pendingFetch.current = setTimeout(() => {
126
+ const results = getRemoteCompletions(value, barn);
127
+ setCompletions(results);
128
+ setLoading(false);
129
+ }, 200);
130
+ }
131
+ return () => {
132
+ if (pendingFetch.current) {
133
+ clearTimeout(pendingFetch.current);
134
+ }
135
+ };
78
136
  }
79
137
  else {
80
138
  // Local completion - immediate
@@ -94,6 +152,10 @@ export function PathInput({ value, onChange, onSubmit, barn }) {
94
152
  const newPath = displayDir + completions[0] + '/';
95
153
  onChange(newPath);
96
154
  setCursorPos(newPath.length);
155
+ // Pre-fetch the subdirectory contents for faster subsequent completions
156
+ if (barn) {
157
+ prefetchRemoteDirectory(newPath.slice(0, -1), barn);
158
+ }
97
159
  }
98
160
  else if (completions.length > 1) {
99
161
  // Multiple matches - find common prefix