@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/claude-plugin/.claude-plugin/plugin.json +7 -0
- package/claude-plugin/skills/yeehaw-project-setup/SKILL.md +129 -0
- package/claude-plugin/skills/yeehaw-project-setup/references/color-discovery.md +170 -0
- package/claude-plugin/skills/yeehaw-project-setup/references/wiki-templates.md +266 -0
- package/dist/app.js +66 -56
- package/dist/components/Header.d.ts +8 -1
- package/dist/components/Header.js +33 -23
- package/dist/components/LivestockHeader.d.ts +7 -0
- package/dist/components/LivestockHeader.js +122 -0
- package/dist/components/PathInput.js +81 -19
- package/dist/components/SplashScreen.d.ts +5 -0
- package/dist/components/SplashScreen.js +178 -0
- package/dist/index.js +2 -5
- package/dist/lib/tmux-config.js +11 -0
- package/dist/lib/tmux.d.ts +5 -0
- package/dist/lib/tmux.js +57 -7
- package/dist/lib/update-check.d.ts +8 -0
- package/dist/lib/update-check.js +12 -0
- package/dist/mcp-server.js +0 -0
- package/dist/types.d.ts +6 -0
- package/dist/views/BarnContext.js +0 -4
- package/dist/views/GlobalDashboard.d.ts +5 -1
- package/dist/views/GlobalDashboard.js +59 -32
- package/dist/views/IssuesView.js +1 -1
- package/dist/views/LivestockDetailView.d.ts +8 -3
- package/dist/views/LivestockDetailView.js +56 -54
- package/dist/views/LogsView.js +1 -1
- package/dist/views/ProjectContext.js +142 -24
- package/dist/views/WikiView.js +0 -4
- package/package.json +2 -1
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: '
|
|
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: '
|
|
55
|
+
return [...common, { key: 's', label: 'shell' }, { key: 'Esc', label: 'back' }];
|
|
54
56
|
}
|
|
55
57
|
if (viewType === 'wiki') {
|
|
56
|
-
return [...common, { key: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
177
|
-
|
|
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 ===
|
|
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
|
-
//
|
|
292
|
-
if (input === 'q') {
|
|
293
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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 +
|
|
38
|
-
g: Math.min(255, Math.round(rgb.g * liftFactor +
|
|
39
|
-
b: Math.min(255, Math.round(rgb.b * liftFactor +
|
|
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
|
-
//
|
|
49
|
+
// Go from base to a darker version
|
|
45
50
|
startRgb = rgb;
|
|
46
51
|
endRgb = {
|
|
47
|
-
r: Math.round(rgb.r *
|
|
48
|
-
g: Math.round(rgb.g *
|
|
49
|
-
b: Math.round(rgb.b *
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
|
35
|
-
if (!partialPath)
|
|
36
|
-
return [];
|
|
37
|
-
// Verify barn has valid SSH config
|
|
39
|
+
function fetchRemoteDirectoryListing(dir, barn) {
|
|
38
40
|
if (!hasValidSshConfig(barn)) {
|
|
39
|
-
return [];
|
|
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
|
-
//
|
|
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=
|
|
50
|
+
'-o', 'ConnectTimeout=3',
|
|
51
|
+
'-o', 'StrictHostKeyChecking=accept-new',
|
|
51
52
|
`${barn.user}@${barn.host}`,
|
|
52
|
-
`ls -
|
|
53
|
-
], { timeout:
|
|
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
|
-
|
|
105
|
+
const pendingFetch = useRef(null);
|
|
106
|
+
// Update completions when value changes
|
|
68
107
|
useEffect(() => {
|
|
69
108
|
if (barn) {
|
|
70
|
-
// Remote completion -
|
|
71
|
-
|
|
72
|
-
const
|
|
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
|
-
}
|
|
77
|
-
|
|
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
|