stratanodex 0.1.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/README.md +251 -0
- package/dist/api/ApiError.js +10 -0
- package/dist/api/client.js +96 -0
- package/dist/commands/add.js +45 -0
- package/dist/commands/config.js +41 -0
- package/dist/commands/done.js +39 -0
- package/dist/commands/executor.js +457 -0
- package/dist/commands/list.js +86 -0
- package/dist/commands/login.js +1 -0
- package/dist/commands/loginFlow.js +86 -0
- package/dist/commands/logout.js +11 -0
- package/dist/commands/registry.js +351 -0
- package/dist/commands/resolver.js +184 -0
- package/dist/config.js +16 -0
- package/dist/index.js +77 -0
- package/dist/tui/App.js +141 -0
- package/dist/tui/components/AutocompleteOverlay.js +22 -0
- package/dist/tui/components/BottomBar.js +8 -0
- package/dist/tui/components/Breadcrumb.js +6 -0
- package/dist/tui/components/CommandInput.js +111 -0
- package/dist/tui/components/CommandPalette.js +1 -0
- package/dist/tui/components/ErrorBoundary.js +26 -0
- package/dist/tui/components/FocusMode.js +1 -0
- package/dist/tui/components/FolderItem.js +5 -0
- package/dist/tui/components/Header.js +5 -0
- package/dist/tui/components/Keybindings.js +5 -0
- package/dist/tui/components/ListItem.js +5 -0
- package/dist/tui/components/NodeRow.js +14 -0
- package/dist/tui/components/PriorityBadge.js +9 -0
- package/dist/tui/components/SearchOverlay.js +1 -0
- package/dist/tui/components/Spinner.js +23 -0
- package/dist/tui/components/StatusBadge.js +9 -0
- package/dist/tui/components/SuggestionItem.js +8 -0
- package/dist/tui/components/TopBar.js +13 -0
- package/dist/tui/components/TreeConnector.js +17 -0
- package/dist/tui/hooks/useAuth.js +37 -0
- package/dist/tui/hooks/useCommandInput.js +35 -0
- package/dist/tui/hooks/useFolders.js +16 -0
- package/dist/tui/hooks/useKeymap.js +30 -0
- package/dist/tui/hooks/useLists.js +16 -0
- package/dist/tui/hooks/useNavigation.js +15 -0
- package/dist/tui/hooks/useTree.js +93 -0
- package/dist/tui/screens/DailyScreen.js +77 -0
- package/dist/tui/screens/DashboardScreen.js +262 -0
- package/dist/tui/screens/HomeScreen.js +75 -0
- package/dist/tui/screens/ListsScreen.js +73 -0
- package/dist/tui/screens/LoginScreen.js +115 -0
- package/dist/tui/screens/NodeScreen.js +48 -0
- package/dist/tui/screens/TreeScreen.js +182 -0
- package/dist/tui/screens/WelcomeScreen.js +83 -0
- package/dist/tui/types.js +1 -0
- package/dist/types/index.js +1 -0
- package/dist/utils/auth.js +11 -0
- package/dist/utils/logger.js +32 -0
- package/dist/utils/numbering.js +38 -0
- package/dist/utils/recents.js +24 -0
- package/dist/utils/scoring.js +29 -0
- package/dist/utils/tree.js +125 -0
- package/package.json +74 -0
package/dist/tui/App.js
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState, useEffect, useRef, useCallback } from 'react';
|
|
3
|
+
import { Box, Text, useApp, useStdout } from 'ink';
|
|
4
|
+
import { useNavigation } from './hooks/useNavigation.js';
|
|
5
|
+
import { useAuth } from './hooks/useAuth.js';
|
|
6
|
+
import { useKeymap } from './hooks/useKeymap.js';
|
|
7
|
+
import { TopBar } from './components/TopBar.js';
|
|
8
|
+
import { CommandInput } from './components/CommandInput.js';
|
|
9
|
+
import { ErrorBoundary } from './components/ErrorBoundary.js';
|
|
10
|
+
import { WelcomeScreen } from './screens/WelcomeScreen.js';
|
|
11
|
+
import { LoginScreen } from './screens/LoginScreen.js';
|
|
12
|
+
import { HomeScreen } from './screens/HomeScreen.js';
|
|
13
|
+
import { ListsScreen } from './screens/ListsScreen.js';
|
|
14
|
+
import { TreeScreen } from './screens/TreeScreen.js';
|
|
15
|
+
import { DailyScreen } from './screens/DailyScreen.js';
|
|
16
|
+
import { DashboardScreen } from './screens/DashboardScreen.js';
|
|
17
|
+
import { NodeScreen } from './screens/NodeScreen.js';
|
|
18
|
+
import { executeCommand } from '../commands/executor.js';
|
|
19
|
+
const TOP_HEIGHT = 5;
|
|
20
|
+
const BOTTOM_BASE_ROWS = 3; // separator + prompt + hint
|
|
21
|
+
/** Map TUI screen names to the registry Screen type for autocomplete. */
|
|
22
|
+
const TUI_TO_REGISTRY = {
|
|
23
|
+
home: 'folders',
|
|
24
|
+
lists: 'lists',
|
|
25
|
+
tree: 'nodes',
|
|
26
|
+
daily: 'nodes',
|
|
27
|
+
'node-details': 'nodes',
|
|
28
|
+
dashboard: 'global',
|
|
29
|
+
welcome: 'global',
|
|
30
|
+
login: 'global',
|
|
31
|
+
};
|
|
32
|
+
export function App() {
|
|
33
|
+
const { exit } = useApp();
|
|
34
|
+
const { stdout } = useStdout();
|
|
35
|
+
const terminalHeight = stdout?.rows ?? 24;
|
|
36
|
+
const terminalWidth = stdout?.columns ?? 80;
|
|
37
|
+
const { currentScreen, push, pop, replaceScreen } = useNavigation();
|
|
38
|
+
const { isLoggedIn, user: authUser } = useAuth();
|
|
39
|
+
const [mode, setMode] = useState('nav');
|
|
40
|
+
const activeHandlers = useRef({});
|
|
41
|
+
const [cmdResult, setCmdResult] = useState(null);
|
|
42
|
+
/** Nodes available in the current screen (populated by TreeScreen/DailyScreen). */
|
|
43
|
+
const [screenNodes, setScreenNodes] = useState([]);
|
|
44
|
+
/** Currently selected node ID (cursor position in TreeScreen). */
|
|
45
|
+
const [selectedNodeId, setSelectedNodeId] = useState(undefined);
|
|
46
|
+
const registerActions = useCallback((handlers) => {
|
|
47
|
+
activeHandlers.current = handlers;
|
|
48
|
+
}, []);
|
|
49
|
+
/** True when the autocomplete overlay is open — keymap skips ESC in that state */
|
|
50
|
+
const overlayOpen = useRef(false);
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
activeHandlers.current = {};
|
|
53
|
+
setScreenNodes([]);
|
|
54
|
+
setCmdResult(null);
|
|
55
|
+
}, [currentScreen.name]);
|
|
56
|
+
const handleCommandSubmit = useCallback(async (raw) => {
|
|
57
|
+
setMode('nav');
|
|
58
|
+
const registryScreen = TUI_TO_REGISTRY[currentScreen.name] ?? 'global';
|
|
59
|
+
const p = currentScreen.params ?? {};
|
|
60
|
+
const result = await executeCommand(raw, registryScreen, {
|
|
61
|
+
listId: p['listId'],
|
|
62
|
+
folderId: p['folderId'],
|
|
63
|
+
currentNodes: screenNodes,
|
|
64
|
+
selectedNodeId,
|
|
65
|
+
navigate: (screen, params) => {
|
|
66
|
+
if (screen === '__pop__')
|
|
67
|
+
pop();
|
|
68
|
+
else
|
|
69
|
+
push(screen, params);
|
|
70
|
+
},
|
|
71
|
+
exit,
|
|
72
|
+
refetch: () => activeHandlers.current.onRefetch?.(),
|
|
73
|
+
});
|
|
74
|
+
if (result.message)
|
|
75
|
+
setCmdResult(result.message);
|
|
76
|
+
setTimeout(() => setCmdResult(null), 3000);
|
|
77
|
+
}, [currentScreen, screenNodes, selectedNodeId, push, pop, exit]);
|
|
78
|
+
useKeymap(mode, overlayOpen, {
|
|
79
|
+
onUp: () => activeHandlers.current.onUp?.(),
|
|
80
|
+
onDown: () => activeHandlers.current.onDown?.(),
|
|
81
|
+
onLeft: () => activeHandlers.current.onLeft?.(),
|
|
82
|
+
onRight: () => activeHandlers.current.onRight?.(),
|
|
83
|
+
onEnter: () => activeHandlers.current.onEnter?.(),
|
|
84
|
+
onBack: () => {
|
|
85
|
+
if (mode === 'edit') {
|
|
86
|
+
setMode('nav');
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
activeHandlers.current.onBack?.();
|
|
90
|
+
},
|
|
91
|
+
onEsc: () => {
|
|
92
|
+
setMode('nav');
|
|
93
|
+
},
|
|
94
|
+
onQuit: () => {
|
|
95
|
+
if (mode === 'nav') {
|
|
96
|
+
const h = activeHandlers.current.onQuit;
|
|
97
|
+
if (h)
|
|
98
|
+
h();
|
|
99
|
+
else
|
|
100
|
+
exit();
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
const middleHeight = Math.max(3, terminalHeight - TOP_HEIGHT - BOTTOM_BASE_ROWS);
|
|
105
|
+
const screenProps = {
|
|
106
|
+
push,
|
|
107
|
+
pop,
|
|
108
|
+
replaceScreen,
|
|
109
|
+
registerActions,
|
|
110
|
+
height: middleHeight,
|
|
111
|
+
width: terminalWidth,
|
|
112
|
+
};
|
|
113
|
+
const registryScreen = TUI_TO_REGISTRY[currentScreen.name] ?? 'global';
|
|
114
|
+
function renderScreen() {
|
|
115
|
+
const name = currentScreen.name;
|
|
116
|
+
const p = currentScreen.params ?? {};
|
|
117
|
+
switch (name) {
|
|
118
|
+
case 'welcome':
|
|
119
|
+
return _jsx(WelcomeScreen, { ...screenProps });
|
|
120
|
+
case 'login':
|
|
121
|
+
return _jsx(LoginScreen, { ...screenProps });
|
|
122
|
+
case 'home':
|
|
123
|
+
return _jsx(HomeScreen, { ...screenProps });
|
|
124
|
+
case 'lists':
|
|
125
|
+
return (_jsx(ListsScreen, { ...screenProps, folderId: p['folderId'] ?? '', folderName: p['folderName'] }));
|
|
126
|
+
case 'tree':
|
|
127
|
+
return (_jsx(TreeScreen, { ...screenProps, listId: p['listId'] ?? '', listName: p['listName'], folderName: p['folderName'], onNodesLoaded: setScreenNodes, onSelectedNodeChanged: setSelectedNodeId }));
|
|
128
|
+
case 'daily':
|
|
129
|
+
return _jsx(DailyScreen, { ...screenProps });
|
|
130
|
+
case 'node-details':
|
|
131
|
+
return _jsx(NodeScreen, { ...screenProps, nodeId: p['nodeId'] });
|
|
132
|
+
case 'dashboard':
|
|
133
|
+
return _jsx(DashboardScreen, { ...screenProps, authUser: authUser });
|
|
134
|
+
default:
|
|
135
|
+
return _jsxs(Text, { color: "red", children: ["Unknown screen: ", name] });
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return (_jsxs(Box, { flexDirection: "column", width: terminalWidth, height: terminalHeight, children: [_jsx(Box, { height: TOP_HEIGHT, flexShrink: 0, children: _jsx(TopBar, { width: terminalWidth, hasToken: isLoggedIn }) }), _jsxs(Box, { flexGrow: 1, flexShrink: 1, overflow: "hidden", flexDirection: "column", children: [_jsx(ErrorBoundary, { children: renderScreen() }), cmdResult && (_jsx(Box, { paddingX: 2, marginTop: 1, children: _jsx(Text, { color: cmdResult.startsWith('✓') ? '#00c896' : 'red', dimColor: true, children: cmdResult }) }))] }), _jsx(Box, { flexShrink: 0, flexDirection: "column", children: _jsx(CommandInput, { screen: registryScreen, currentNodes: screenNodes, width: terminalWidth, onSubmit: handleCommandSubmit, onOverlayChange: (open) => {
|
|
139
|
+
overlayOpen.current = open;
|
|
140
|
+
} }) })] }));
|
|
141
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { SuggestionItem } from './SuggestionItem.js';
|
|
4
|
+
const MAX_VISIBLE = 5;
|
|
5
|
+
export const AutocompleteOverlay = ({ suggestions, selectedIndex, visible, width, }) => {
|
|
6
|
+
if (!visible || suggestions.length === 0)
|
|
7
|
+
return null;
|
|
8
|
+
// Don't render if every suggestion is a placeholder hint (empty fillValue)
|
|
9
|
+
const allPlaceholders = suggestions.every((s) => !s.fillValue || s.isNoMatch);
|
|
10
|
+
if (allPlaceholders)
|
|
11
|
+
return null;
|
|
12
|
+
// Scroll window: keep selectedIndex visible
|
|
13
|
+
const start = Math.max(0, Math.min(selectedIndex - Math.floor(MAX_VISIBLE / 2), suggestions.length - MAX_VISIBLE));
|
|
14
|
+
const visible_items = suggestions.slice(start, start + MAX_VISIBLE);
|
|
15
|
+
const borderWidth = Math.min(width - 4, 60);
|
|
16
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "#00bfff", width: borderWidth, marginLeft: 2, children: [_jsx(Box, { paddingX: 1, children: _jsxs(Text, { color: "#00bfff", dimColor: true, children: ["suggestions", ' ', suggestions.length > MAX_VISIBLE
|
|
17
|
+
? `(${start + 1}–${start + visible_items.length} of ${suggestions.length})`
|
|
18
|
+
: ''] }) }), visible_items.map((s, i) => {
|
|
19
|
+
const absoluteIdx = start + i;
|
|
20
|
+
return (_jsx(SuggestionItem, { suggestion: s, isSelected: absoluteIdx === selectedIndex }, `${s.label}-${absoluteIdx}`));
|
|
21
|
+
})] }));
|
|
22
|
+
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import TextInput from 'ink-text-input';
|
|
4
|
+
import chalk from 'chalk';
|
|
5
|
+
const DEFAULT_HINTS = ['/add', '/done', '/delete', '/list', '/daily', '/exit', '/help'];
|
|
6
|
+
export const BottomBar = ({ value, onChange, onSubmit, width, hints = DEFAULT_HINTS, }) => {
|
|
7
|
+
return (_jsxs(Box, { flexDirection: "column", width: width, children: [_jsx(Box, { width: width, children: _jsx(Text, { children: chalk.hex('#0a2a33')('─'.repeat(width)) }) }), _jsxs(Box, { width: width, paddingX: 2, children: [_jsx(Text, { children: chalk.hex('#00bfff')('> ') }), _jsx(TextInput, { value: value, onChange: onChange, onSubmit: onSubmit, placeholder: "type a command" })] }), _jsx(Box, { width: width, paddingX: 2, gap: 2, children: hints.slice(0, 7).map((hint) => (_jsx(Text, { children: chalk.hex('#003344')(hint) }, hint))) })] }));
|
|
8
|
+
};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { Box, Text } from 'ink';
|
|
4
|
+
export function Breadcrumb({ parts }) {
|
|
5
|
+
return (_jsx(Box, { children: parts.map((part, i) => (_jsxs(React.Fragment, { children: [i > 0 && _jsx(Text, { color: "#3a6a7a", children: " \u203A " }), _jsx(Text, { color: "#00bfff", children: part })] }, i))) }));
|
|
6
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// CommandInput.tsx — Bottom input bar with integrated token-aware autocomplete.
|
|
3
|
+
// Replaces BottomBar for screens that have full command support.
|
|
4
|
+
import { useState, useCallback, useRef } from 'react';
|
|
5
|
+
import { Box, Text, useInput } from 'ink';
|
|
6
|
+
import TextInput from 'ink-text-input';
|
|
7
|
+
import chalk from 'chalk';
|
|
8
|
+
import { resolve } from '../../commands/resolver.js';
|
|
9
|
+
import { AutocompleteOverlay } from './AutocompleteOverlay.js';
|
|
10
|
+
export const CommandInput = ({ screen, currentNodes = [], width, onSubmit, onOverlayChange, }) => {
|
|
11
|
+
const [inputValue, setInputValue] = useState('');
|
|
12
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
13
|
+
const [overlayVisible, setOverlayVisible] = useState(false);
|
|
14
|
+
/** Guard: when true, handleChange ignores calls (fillToken is setting the value) */
|
|
15
|
+
const fillingRef = useRef(false);
|
|
16
|
+
const setOverlay = useCallback((open) => {
|
|
17
|
+
setOverlayVisible(open);
|
|
18
|
+
if (open) {
|
|
19
|
+
onOverlayChange?.(true);
|
|
20
|
+
}
|
|
21
|
+
else {
|
|
22
|
+
setTimeout(() => onOverlayChange?.(false), 0);
|
|
23
|
+
}
|
|
24
|
+
}, [onOverlayChange]);
|
|
25
|
+
const resolved = resolve(inputValue, screen, currentNodes);
|
|
26
|
+
const suggestions = resolved.suggestions;
|
|
27
|
+
// Don't show overlay if all suggestions are just placeholder hints (e.g. "folder name")
|
|
28
|
+
const hasActionableSuggestions = suggestions.length > 0 && suggestions.some((s) => s.fillValue && !s.isNoMatch);
|
|
29
|
+
const showOverlay = overlayVisible && inputValue.startsWith('/') && hasActionableSuggestions;
|
|
30
|
+
const handleChange = useCallback((val) => {
|
|
31
|
+
// Skip if fillToken is active (ink-text-input fires onChange for TAB too)
|
|
32
|
+
if (fillingRef.current)
|
|
33
|
+
return;
|
|
34
|
+
// Strip any tab characters that ink-text-input might inject
|
|
35
|
+
const cleaned = val.replace(/\t/g, '');
|
|
36
|
+
setInputValue(cleaned);
|
|
37
|
+
setSelectedIndex(0);
|
|
38
|
+
setOverlay(cleaned.startsWith('/'));
|
|
39
|
+
}, [setOverlay]);
|
|
40
|
+
const fillToken = useCallback((idx) => {
|
|
41
|
+
const suggestion = suggestions[idx];
|
|
42
|
+
if (!suggestion || suggestion.isNoMatch)
|
|
43
|
+
return;
|
|
44
|
+
// Block handleChange for this tick — ink-text-input will also fire onChange for TAB
|
|
45
|
+
fillingRef.current = true;
|
|
46
|
+
setTimeout(() => {
|
|
47
|
+
fillingRef.current = false;
|
|
48
|
+
}, 0);
|
|
49
|
+
const filled = resolved.filledTokens.join(' ');
|
|
50
|
+
const base = filled.length > 0 ? filled + ' ' : '';
|
|
51
|
+
if (!suggestion.fillValue) {
|
|
52
|
+
setInputValue(base);
|
|
53
|
+
setOverlay(false);
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
const newValue = base + suggestion.fillValue;
|
|
57
|
+
setInputValue(newValue);
|
|
58
|
+
setSelectedIndex(0);
|
|
59
|
+
// Check if the next stage only has placeholder hints
|
|
60
|
+
const nextResolved = resolve(newValue, screen, currentNodes);
|
|
61
|
+
const allPlaceholders = nextResolved.suggestions.length > 0 &&
|
|
62
|
+
nextResolved.suggestions.every((s) => !s.fillValue || s.isNoMatch);
|
|
63
|
+
if (allPlaceholders || nextResolved.suggestions.length === 0) {
|
|
64
|
+
setOverlay(false);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}, [suggestions, resolved.filledTokens, setOverlay, screen, currentNodes]);
|
|
68
|
+
const handleSubmit = useCallback((val) => {
|
|
69
|
+
if (!val.trim())
|
|
70
|
+
return;
|
|
71
|
+
setInputValue('');
|
|
72
|
+
setOverlay(false);
|
|
73
|
+
setSelectedIndex(0);
|
|
74
|
+
onSubmit(val.trim());
|
|
75
|
+
}, [onSubmit, setOverlay]);
|
|
76
|
+
useInput((input, key) => {
|
|
77
|
+
// Tab — fill selected suggestion
|
|
78
|
+
if (key.tab) {
|
|
79
|
+
if (showOverlay) {
|
|
80
|
+
fillToken(selectedIndex);
|
|
81
|
+
}
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
// Arrow up / down — navigate suggestions
|
|
85
|
+
if (key.upArrow) {
|
|
86
|
+
if (showOverlay) {
|
|
87
|
+
setSelectedIndex((i) => Math.max(0, i - 1));
|
|
88
|
+
}
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (key.downArrow) {
|
|
92
|
+
if (showOverlay) {
|
|
93
|
+
setSelectedIndex((i) => Math.min(suggestions.length - 1, i + 1));
|
|
94
|
+
}
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
// Escape — close overlay (event is consumed here, keymap won't see it)
|
|
98
|
+
if (key.escape) {
|
|
99
|
+
if (overlayVisible) {
|
|
100
|
+
setOverlay(false);
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
// Overlay already closed — clear input
|
|
104
|
+
setInputValue('');
|
|
105
|
+
}
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
const borderLine = chalk.hex('#0a2a33')('─'.repeat(width));
|
|
110
|
+
return (_jsxs(Box, { flexDirection: "column", width: width, children: [showOverlay && (_jsx(AutocompleteOverlay, { suggestions: suggestions, selectedIndex: selectedIndex, visible: showOverlay, width: width })), _jsx(Box, { width: width, children: _jsx(Text, { children: borderLine }) }), _jsxs(Box, { width: width, paddingX: 2, children: [_jsx(Text, { children: chalk.hex('#00bfff')('> ') }), _jsx(Box, { children: _jsx(Text, { color: "#e6edf3", children: _jsx(TextInput, { value: inputValue, onChange: handleChange, onSubmit: handleSubmit, placeholder: "type / for commands" }) }) })] }), _jsx(Box, { width: width, paddingX: 2, children: _jsx(Text, { children: chalk.hex('#3a6a7a')('/ for commands ↑↓ navigate TAB complete ESC close Enter execute') }) })] }));
|
|
111
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// ErrorBoundary.tsx — React class component error boundary for the TUI.
|
|
3
|
+
// Catches render errors in screen components and shows a friendly recovery message.
|
|
4
|
+
import React from 'react';
|
|
5
|
+
import { Box, Text } from 'ink';
|
|
6
|
+
export class ErrorBoundary extends React.Component {
|
|
7
|
+
constructor(props) {
|
|
8
|
+
super(props);
|
|
9
|
+
this.state = { hasError: false, error: null };
|
|
10
|
+
}
|
|
11
|
+
static getDerivedStateFromError(error) {
|
|
12
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
13
|
+
return { hasError: true, error: msg };
|
|
14
|
+
}
|
|
15
|
+
componentDidCatch(error, info) {
|
|
16
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
17
|
+
// Write to stderr so it shows in logs without breaking the TUI render
|
|
18
|
+
process.stderr.write(`[StrataNodex] Render error: ${msg}\n${info.componentStack ?? ''}\n`);
|
|
19
|
+
}
|
|
20
|
+
render() {
|
|
21
|
+
if (this.state.hasError) {
|
|
22
|
+
return (_jsxs(Box, { flexDirection: "column", paddingX: 2, paddingY: 1, children: [_jsx(Text, { color: "red", bold: true, children: "\u2717 Something went wrong" }), _jsx(Text, { dimColor: true, children: this.state.error ?? 'Unknown error' }), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: "Type /back to go back \u00B7 /home to return to folders \u00B7 /logout to reset" })] }));
|
|
23
|
+
}
|
|
24
|
+
return this.props.children;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
export function FolderItem({ folder, isSelected }) {
|
|
4
|
+
return (_jsx(Box, { children: _jsx(Text, { bold: isSelected, color: isSelected ? 'yellow' : undefined, children: isSelected ? `❯ ${folder.name}` : ` ${folder.name}` }) }));
|
|
5
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
export function Header({ version, userName }) {
|
|
4
|
+
return (_jsxs(Box, { flexDirection: "column", alignItems: "center", paddingY: 1, children: [_jsx(Text, { bold: true, color: "white", children: "StrataNodex - CLI" }), _jsxs(Text, { dimColor: true, children: ["v ", version] }), _jsx(Text, { dimColor: true, children: userName ? `Welcome Back, ${userName}` : 'Welcome, Guest' })] }));
|
|
5
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
export function ListItem({ list, isSelected }) {
|
|
4
|
+
return (_jsx(Box, { children: _jsx(Text, { bold: isSelected, color: isSelected ? 'yellow' : undefined, children: isSelected ? `❯ ${list.name}` : ` ${list.name}` }) }));
|
|
5
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { TreeConnector } from './TreeConnector.js';
|
|
4
|
+
const STATUS_ICON = {
|
|
5
|
+
TODO: { icon: '○', color: '#8b949e' },
|
|
6
|
+
IN_PROGRESS: { icon: '◑', color: '#00bfff' },
|
|
7
|
+
DONE: { icon: '●', color: '#00c896' },
|
|
8
|
+
};
|
|
9
|
+
const ROOT_INDENT = ' ';
|
|
10
|
+
export function NodeRow({ node, number, depth, isSelected, isLast, isLastRoot, parentLines, }) {
|
|
11
|
+
const title = node.title.length > 50 ? node.title.slice(0, 47) + '...' : node.title;
|
|
12
|
+
const statusInfo = STATUS_ICON[node.status] ?? STATUS_ICON['TODO'];
|
|
13
|
+
return (_jsxs(Box, { children: [depth === 0 ? (_jsx(Text, { color: "#2a5a6a", children: ROOT_INDENT })) : (_jsxs(_Fragment, { children: [_jsx(Text, { color: "#2a5a6a", children: ROOT_INDENT }), _jsx(TreeConnector, { depth: depth, parentLines: parentLines, isLast: isLast })] })), _jsx(Text, { color: statusInfo.color, children: statusInfo.icon }), _jsx(Text, { children: " " }), number && (_jsx(Text, { color: isSelected ? '#00bfff' : '#3d6a7a', dimColor: !isSelected, children: number })), number && _jsx(Text, { children: " " }), isSelected ? (_jsx(Text, { bold: true, color: "#00bfff", children: title })) : (_jsx(Text, { color: "#c9d1d9", children: title }))] }));
|
|
14
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Text } from 'ink';
|
|
3
|
+
export function PriorityBadge({ priority }) {
|
|
4
|
+
if (priority === 'HIGH')
|
|
5
|
+
return _jsx(Text, { color: "red", children: "[HIGH]" });
|
|
6
|
+
if (priority === 'LOW')
|
|
7
|
+
return _jsx(Text, { dimColor: true, children: "[LOW]" });
|
|
8
|
+
return _jsx(Text, { color: "yellow", children: "[MED]" });
|
|
9
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// Spinner.tsx — A simple terminal spinner using braille frames.
|
|
3
|
+
// Respects NO_COLOR: falls back to plain "..." when set.
|
|
4
|
+
import { useState, useEffect } from 'react';
|
|
5
|
+
import { Text } from 'ink';
|
|
6
|
+
const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
7
|
+
const INTERVAL_MS = 80;
|
|
8
|
+
export const Spinner = ({ label = 'Loading...' }) => {
|
|
9
|
+
const noColor = Boolean(process.env['NO_COLOR']);
|
|
10
|
+
const [frame, setFrame] = useState(0);
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
if (noColor)
|
|
13
|
+
return;
|
|
14
|
+
const timer = setInterval(() => {
|
|
15
|
+
setFrame((f) => (f + 1) % FRAMES.length);
|
|
16
|
+
}, INTERVAL_MS);
|
|
17
|
+
return () => clearInterval(timer);
|
|
18
|
+
}, [noColor]);
|
|
19
|
+
if (noColor) {
|
|
20
|
+
return _jsxs(Text, { dimColor: true, children: ["... ", label] });
|
|
21
|
+
}
|
|
22
|
+
return (_jsxs(Text, { color: "#00bfff", children: [FRAMES[frame], " ", label] }));
|
|
23
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Text } from 'ink';
|
|
3
|
+
export function StatusBadge({ status }) {
|
|
4
|
+
if (status === 'DONE')
|
|
5
|
+
return (_jsx(Text, { color: "green", dimColor: true, children: "[DONE]" }));
|
|
6
|
+
if (status === 'IN_PROGRESS')
|
|
7
|
+
return _jsx(Text, { color: "blue", children: "[IN_PROG]" });
|
|
8
|
+
return _jsx(Text, { children: "[TODO]" });
|
|
9
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
export const SuggestionItem = ({ suggestion, isSelected }) => {
|
|
4
|
+
if (suggestion.isNoMatch) {
|
|
5
|
+
return (_jsx(Box, { paddingX: 1, children: _jsx(Text, { color: "#ff6b6b", children: "\u2715 No match found" }) }));
|
|
6
|
+
}
|
|
7
|
+
return (_jsxs(Box, { paddingX: 1, gap: 1, children: [_jsxs(Text, { color: isSelected ? '#00bfff' : '#8b949e', children: [isSelected ? '>' : ' ', " ", suggestion.label] }), suggestion.hint && (_jsx(Text, { color: "#00c896", dimColor: true, children: suggestion.hint }))] }));
|
|
8
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
import { createRequire } from 'module';
|
|
5
|
+
const require = createRequire(import.meta.url);
|
|
6
|
+
const { version } = require('../../../package.json');
|
|
7
|
+
const LOGO_LINE_1 = '█▀▀ ▀█▀ █▀█ ▄▀█ ▀█▀ ▄▀█ █▄ █ █▀█ █▀▄ █▀▀ ▀▄▀';
|
|
8
|
+
const LOGO_LINE_2 = '▄██ █ █▀▄ █▀█ █ █▀█ █ ▀█ █▄█ █▄▀ ██▄ █ █';
|
|
9
|
+
export const TopBar = ({ width, hasToken }) => {
|
|
10
|
+
return (_jsxs(Box, { flexDirection: "column", width: width, children: [_jsx(Box, { width: width, children: _jsx(Text, { children: chalk.hex('#1a4a5a')('▀'.repeat(width)) }) }), _jsx(Box, { width: width, paddingX: 2, children: _jsx(Text, { children: chalk.hex('#004477')(LOGO_LINE_1) }) }), _jsx(Box, { width: width, paddingX: 2, children: _jsx(Text, { children: chalk.hex('#00bfff').bold(LOGO_LINE_1) }) }), _jsxs(Box, { width: width, justifyContent: "space-between", paddingX: 2, children: [_jsx(Text, { children: chalk.hex('#00bfff').bold(LOGO_LINE_2) }), _jsxs(Text, { children: [chalk.hex('#004455')(`v${version}`), ' ', hasToken
|
|
11
|
+
? chalk.hex('#007799')('● connected')
|
|
12
|
+
: chalk.hex('#440000')('● not logged in')] })] }), _jsx(Box, { width: width, children: _jsx(Text, { children: chalk.hex('#0a2a33')('─'.repeat(width)) }) })] }));
|
|
13
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Text } from 'ink';
|
|
3
|
+
// Each depth level = 4 chars wide
|
|
4
|
+
const CONT = '│ '; // vertical + 3 spaces
|
|
5
|
+
const BLANK = ' '; // 4 spaces
|
|
6
|
+
const MID = '├── '; // branch mid-sibling
|
|
7
|
+
const END = '└── '; // branch last-sibling
|
|
8
|
+
export function TreeConnector({ depth, parentLines, isLast }) {
|
|
9
|
+
if (depth === 0)
|
|
10
|
+
return null;
|
|
11
|
+
let prefix = '';
|
|
12
|
+
for (let i = 0; i < depth - 1; i++) {
|
|
13
|
+
prefix += parentLines[i] ? CONT : BLANK;
|
|
14
|
+
}
|
|
15
|
+
prefix += isLast ? END : MID;
|
|
16
|
+
return _jsx(Text, { color: "#2a5a6a", children: prefix });
|
|
17
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Auth hook
|
|
2
|
+
import { useState, useEffect, useRef } from 'react';
|
|
3
|
+
import { getToken } from '../../utils/auth.js';
|
|
4
|
+
import { getMe } from '../../api/client.js';
|
|
5
|
+
export function useAuth() {
|
|
6
|
+
const [state, setState] = useState({ isLoggedIn: false, user: null, loading: true });
|
|
7
|
+
const fetchedRef = useRef(false);
|
|
8
|
+
useEffect(() => {
|
|
9
|
+
// GUEST MODE — for local testing only
|
|
10
|
+
if (process.env['STRATANODEX_GUEST'] === 'true') {
|
|
11
|
+
setState({ isLoggedIn: true, user: { name: 'Guest', email: 'guest' }, loading: false });
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
const tryAuth = () => {
|
|
15
|
+
if (fetchedRef.current)
|
|
16
|
+
return; // already resolved, stop polling
|
|
17
|
+
const token = getToken();
|
|
18
|
+
if (!token) {
|
|
19
|
+
setState({ isLoggedIn: false, user: null, loading: false });
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
// Token just appeared — fetch user and stop polling
|
|
23
|
+
fetchedRef.current = true;
|
|
24
|
+
getMe()
|
|
25
|
+
.then((u) => setState({ isLoggedIn: true, user: { name: u.name, email: u.email }, loading: false }))
|
|
26
|
+
.catch(() => {
|
|
27
|
+
fetchedRef.current = false; // allow retry
|
|
28
|
+
setState({ isLoggedIn: false, user: null, loading: false });
|
|
29
|
+
});
|
|
30
|
+
};
|
|
31
|
+
// Run immediately, then poll every 500ms until token is found
|
|
32
|
+
tryAuth();
|
|
33
|
+
const interval = setInterval(tryAuth, 500);
|
|
34
|
+
return () => clearInterval(interval);
|
|
35
|
+
}, []);
|
|
36
|
+
return state;
|
|
37
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { useState, useCallback } from 'react';
|
|
2
|
+
export function useCommandInput({ onExit, onNavigate, onScreenCommand }) {
|
|
3
|
+
const [inputValue, setInputValue] = useState('');
|
|
4
|
+
const handleSubmit = useCallback((value) => {
|
|
5
|
+
const trimmed = value.trim();
|
|
6
|
+
setInputValue('');
|
|
7
|
+
if (!trimmed)
|
|
8
|
+
return;
|
|
9
|
+
if (!trimmed.startsWith('/')) {
|
|
10
|
+
onScreenCommand(trimmed);
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
const [cmd, ...args] = trimmed.slice(1).split(' ');
|
|
14
|
+
const _argStr = args.join(' ');
|
|
15
|
+
switch (cmd.toLowerCase()) {
|
|
16
|
+
case 'exit':
|
|
17
|
+
case 'quit':
|
|
18
|
+
onExit();
|
|
19
|
+
break;
|
|
20
|
+
case 'help':
|
|
21
|
+
onNavigate('help');
|
|
22
|
+
break;
|
|
23
|
+
case 'list':
|
|
24
|
+
onNavigate('home');
|
|
25
|
+
break;
|
|
26
|
+
case 'daily':
|
|
27
|
+
onNavigate('daily');
|
|
28
|
+
break;
|
|
29
|
+
default:
|
|
30
|
+
onScreenCommand(trimmed);
|
|
31
|
+
break;
|
|
32
|
+
}
|
|
33
|
+
}, [onExit, onNavigate, onScreenCommand]);
|
|
34
|
+
return { inputValue, setInputValue, handleSubmit };
|
|
35
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Folders hook
|
|
2
|
+
import { useState, useEffect, useCallback } from 'react';
|
|
3
|
+
import { getFolders } from '../../api/client.js';
|
|
4
|
+
export function useFolders() {
|
|
5
|
+
const [state, setState] = useState({ folders: [], loading: true, error: null });
|
|
6
|
+
const fetch = useCallback(() => {
|
|
7
|
+
setState((s) => ({ ...s, loading: true, error: null }));
|
|
8
|
+
getFolders()
|
|
9
|
+
.then((folders) => setState({ folders, loading: false, error: null }))
|
|
10
|
+
.catch((err) => setState({ folders: [], loading: false, error: err.message }));
|
|
11
|
+
}, []);
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
fetch();
|
|
14
|
+
}, [fetch]);
|
|
15
|
+
return { ...state, refetch: fetch };
|
|
16
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { useInput } from 'ink';
|
|
2
|
+
export function useKeymap(mode, overlayOpen, handlers) {
|
|
3
|
+
useInput((input, key) => {
|
|
4
|
+
// When the autocomplete overlay is open, CommandInput handles all keys.
|
|
5
|
+
// Let it consume them — don't fire any navigation actions here.
|
|
6
|
+
if (overlayOpen.current)
|
|
7
|
+
return;
|
|
8
|
+
if (mode === 'edit') {
|
|
9
|
+
if (key.escape)
|
|
10
|
+
handlers.onEsc?.();
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
if (key.upArrow)
|
|
14
|
+
handlers.onUp?.();
|
|
15
|
+
else if (key.downArrow)
|
|
16
|
+
handlers.onDown?.();
|
|
17
|
+
else if (key.leftArrow)
|
|
18
|
+
handlers.onLeft?.();
|
|
19
|
+
else if (key.rightArrow)
|
|
20
|
+
handlers.onRight?.();
|
|
21
|
+
else if (key.return)
|
|
22
|
+
handlers.onEnter?.();
|
|
23
|
+
else if (key.escape)
|
|
24
|
+
handlers.onEsc?.();
|
|
25
|
+
else if (input === 'b')
|
|
26
|
+
handlers.onBack?.();
|
|
27
|
+
else if (input === 'q')
|
|
28
|
+
handlers.onQuit?.();
|
|
29
|
+
});
|
|
30
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Lists hook
|
|
2
|
+
import { useState, useEffect, useCallback } from 'react';
|
|
3
|
+
import { getLists } from '../../api/client.js';
|
|
4
|
+
export function useLists(folderId) {
|
|
5
|
+
const [state, setState] = useState({ lists: [], loading: true, error: null });
|
|
6
|
+
const fetch = useCallback(() => {
|
|
7
|
+
setState((s) => ({ ...s, loading: true, error: null }));
|
|
8
|
+
getLists(folderId)
|
|
9
|
+
.then((lists) => setState({ lists, loading: false, error: null }))
|
|
10
|
+
.catch((err) => setState({ lists: [], loading: false, error: err.message }));
|
|
11
|
+
}, [folderId]);
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
fetch();
|
|
14
|
+
}, [fetch]);
|
|
15
|
+
return { ...state, refetch: fetch };
|
|
16
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { useState, useCallback } from 'react';
|
|
2
|
+
export function useNavigation() {
|
|
3
|
+
const [stack, setStack] = useState([{ name: 'welcome' }]);
|
|
4
|
+
const currentScreen = stack[stack.length - 1];
|
|
5
|
+
const push = useCallback((name, params) => {
|
|
6
|
+
setStack((s) => [...s, { name, params }]);
|
|
7
|
+
}, []);
|
|
8
|
+
const pop = useCallback(() => {
|
|
9
|
+
setStack((s) => (s.length > 1 ? s.slice(0, -1) : s));
|
|
10
|
+
}, []);
|
|
11
|
+
const replaceScreen = useCallback((name, params) => {
|
|
12
|
+
setStack((prev) => [...prev.slice(0, -1), { name, params }]);
|
|
13
|
+
}, []);
|
|
14
|
+
return { currentScreen, stack, push, pop, replaceScreen };
|
|
15
|
+
}
|