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.
Files changed (59) hide show
  1. package/README.md +251 -0
  2. package/dist/api/ApiError.js +10 -0
  3. package/dist/api/client.js +96 -0
  4. package/dist/commands/add.js +45 -0
  5. package/dist/commands/config.js +41 -0
  6. package/dist/commands/done.js +39 -0
  7. package/dist/commands/executor.js +457 -0
  8. package/dist/commands/list.js +86 -0
  9. package/dist/commands/login.js +1 -0
  10. package/dist/commands/loginFlow.js +86 -0
  11. package/dist/commands/logout.js +11 -0
  12. package/dist/commands/registry.js +351 -0
  13. package/dist/commands/resolver.js +184 -0
  14. package/dist/config.js +16 -0
  15. package/dist/index.js +77 -0
  16. package/dist/tui/App.js +141 -0
  17. package/dist/tui/components/AutocompleteOverlay.js +22 -0
  18. package/dist/tui/components/BottomBar.js +8 -0
  19. package/dist/tui/components/Breadcrumb.js +6 -0
  20. package/dist/tui/components/CommandInput.js +111 -0
  21. package/dist/tui/components/CommandPalette.js +1 -0
  22. package/dist/tui/components/ErrorBoundary.js +26 -0
  23. package/dist/tui/components/FocusMode.js +1 -0
  24. package/dist/tui/components/FolderItem.js +5 -0
  25. package/dist/tui/components/Header.js +5 -0
  26. package/dist/tui/components/Keybindings.js +5 -0
  27. package/dist/tui/components/ListItem.js +5 -0
  28. package/dist/tui/components/NodeRow.js +14 -0
  29. package/dist/tui/components/PriorityBadge.js +9 -0
  30. package/dist/tui/components/SearchOverlay.js +1 -0
  31. package/dist/tui/components/Spinner.js +23 -0
  32. package/dist/tui/components/StatusBadge.js +9 -0
  33. package/dist/tui/components/SuggestionItem.js +8 -0
  34. package/dist/tui/components/TopBar.js +13 -0
  35. package/dist/tui/components/TreeConnector.js +17 -0
  36. package/dist/tui/hooks/useAuth.js +37 -0
  37. package/dist/tui/hooks/useCommandInput.js +35 -0
  38. package/dist/tui/hooks/useFolders.js +16 -0
  39. package/dist/tui/hooks/useKeymap.js +30 -0
  40. package/dist/tui/hooks/useLists.js +16 -0
  41. package/dist/tui/hooks/useNavigation.js +15 -0
  42. package/dist/tui/hooks/useTree.js +93 -0
  43. package/dist/tui/screens/DailyScreen.js +77 -0
  44. package/dist/tui/screens/DashboardScreen.js +262 -0
  45. package/dist/tui/screens/HomeScreen.js +75 -0
  46. package/dist/tui/screens/ListsScreen.js +73 -0
  47. package/dist/tui/screens/LoginScreen.js +115 -0
  48. package/dist/tui/screens/NodeScreen.js +48 -0
  49. package/dist/tui/screens/TreeScreen.js +182 -0
  50. package/dist/tui/screens/WelcomeScreen.js +83 -0
  51. package/dist/tui/types.js +1 -0
  52. package/dist/types/index.js +1 -0
  53. package/dist/utils/auth.js +11 -0
  54. package/dist/utils/logger.js +32 -0
  55. package/dist/utils/numbering.js +38 -0
  56. package/dist/utils/recents.js +24 -0
  57. package/dist/utils/scoring.js +29 -0
  58. package/dist/utils/tree.js +125 -0
  59. package/package.json +74 -0
@@ -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 { Text } from 'ink';
3
+ export function Keybindings({ bindings }) {
4
+ return _jsx(Text, { dimColor: true, children: bindings });
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
+ }