claude-think 0.2.1 → 0.3.1
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/settings.local.json +2 -1
- package/CHANGELOG.md +24 -0
- package/README.md +4 -3
- package/bun.lock +3 -0
- package/package.json +2 -1
- package/src/tui/App.tsx +123 -17
- package/src/tui/components/Help.tsx +58 -28
- package/src/tui/components/Navigation.tsx +50 -12
- package/src/tui/components/Preview.tsx +86 -0
- package/src/tui/components/QuickActions.tsx +155 -0
- package/src/tui/components/Search.tsx +129 -0
- package/src/tui/components/StatusBar.tsx +84 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,30 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.3.1] - 2025-02-05
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
- Responsive TUI - adapts to terminal width:
|
|
12
|
+
- Narrow terminals (<80 cols): compact nav labels, shorter status
|
|
13
|
+
- Very narrow (<50 cols): minimal header, single nav indicator
|
|
14
|
+
- Footer shortcuts adjust to available space
|
|
15
|
+
|
|
16
|
+
## [0.3.0] - 2025-02-05
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
- Enhanced TUI with new features:
|
|
20
|
+
- **Status bar** - shows learnings count, pending count, last sync time
|
|
21
|
+
- **Quick actions menu** (press `a`) - sync, add learning, search
|
|
22
|
+
- **Preview panel** (press `p`) - view generated CLAUDE.md with scrolling
|
|
23
|
+
- **Search** (press `/`) - search across all config files
|
|
24
|
+
- **Better help screen** (press `?`) - organized shortcuts and CLI commands
|
|
25
|
+
- New keyboard shortcuts: `a` actions, `p` preview, `/` search, `Ctrl+S` sync
|
|
26
|
+
- Bordered content panels for cleaner visual design
|
|
27
|
+
|
|
28
|
+
### Changed
|
|
29
|
+
- TUI now uses modals for actions, preview, search, and help
|
|
30
|
+
- Improved navigation hints in footer
|
|
31
|
+
|
|
8
32
|
## [0.2.1] - 2025-02-05
|
|
9
33
|
|
|
10
34
|
### Added
|
package/README.md
CHANGED
|
@@ -25,7 +25,7 @@ Personal context manager for Claude. Stop repeating yourself.
|
|
|
25
25
|
|
|
26
26
|
```bash
|
|
27
27
|
# With bun (recommended)
|
|
28
|
-
bun
|
|
28
|
+
bun add -g claude-think
|
|
29
29
|
|
|
30
30
|
# With npm
|
|
31
31
|
npm install -g claude-think
|
|
@@ -47,8 +47,8 @@ claude
|
|
|
47
47
|
## How it works
|
|
48
48
|
|
|
49
49
|
1. Your preferences live in `~/.think/` (markdown files)
|
|
50
|
-
2. `think sync` generates
|
|
51
|
-
3. Claude Code auto-loads
|
|
50
|
+
2. `think sync` generates `~/.claude/CLAUDE.md`
|
|
51
|
+
3. Claude Code auto-loads CLAUDE.md at session start
|
|
52
52
|
|
|
53
53
|
## Commands
|
|
54
54
|
|
|
@@ -65,6 +65,7 @@ claude
|
|
|
65
65
|
| `think edit <file>` | Edit any config file |
|
|
66
66
|
| `think allow "cmd"` | Pre-approve a command |
|
|
67
67
|
| `think tree` | Preview project file tree |
|
|
68
|
+
| `think project learn` | Generate CLAUDE.md for current project |
|
|
68
69
|
| `think help` | Show all commands |
|
|
69
70
|
|
|
70
71
|
## What you can configure
|
package/bun.lock
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
"commander": "^14.0.3",
|
|
11
11
|
"gray-matter": "^4.0.3",
|
|
12
12
|
"ink": "^6.6.0",
|
|
13
|
+
"ink-text-input": "^6.0.0",
|
|
13
14
|
"react": "^19.2.4",
|
|
14
15
|
"string-similarity": "^4.0.4",
|
|
15
16
|
},
|
|
@@ -86,6 +87,8 @@
|
|
|
86
87
|
|
|
87
88
|
"ink": ["ink@6.6.0", "", { "dependencies": { "@alcalzone/ansi-tokenize": "^0.2.1", "ansi-escapes": "^7.2.0", "ansi-styles": "^6.2.1", "auto-bind": "^5.0.1", "chalk": "^5.6.0", "cli-boxes": "^3.0.0", "cli-cursor": "^4.0.0", "cli-truncate": "^5.1.1", "code-excerpt": "^4.0.0", "es-toolkit": "^1.39.10", "indent-string": "^5.0.0", "is-in-ci": "^2.0.0", "patch-console": "^2.0.0", "react-reconciler": "^0.33.0", "signal-exit": "^3.0.7", "slice-ansi": "^7.1.0", "stack-utils": "^2.0.6", "string-width": "^8.1.0", "type-fest": "^4.27.0", "widest-line": "^5.0.0", "wrap-ansi": "^9.0.0", "ws": "^8.18.0", "yoga-layout": "~3.2.1" }, "peerDependencies": { "@types/react": ">=19.0.0", "react": ">=19.0.0", "react-devtools-core": "^6.1.2" }, "optionalPeers": ["@types/react", "react-devtools-core"] }, "sha512-QDt6FgJxgmSxAelcOvOHUvFxbIUjVpCH5bx+Slvc5m7IEcpGt3dYwbz/L+oRnqEGeRvwy1tineKK4ect3nW1vQ=="],
|
|
88
89
|
|
|
90
|
+
"ink-text-input": ["ink-text-input@6.0.0", "", { "dependencies": { "chalk": "^5.3.0", "type-fest": "^4.18.2" }, "peerDependencies": { "ink": ">=5", "react": ">=18" } }, "sha512-Fw64n7Yha5deb1rHY137zHTAbSTNelUKuB5Kkk2HACXEtwIHBCf9OH2tP/LQ9fRYTl1F0dZgbW0zPnZk6FA9Lw=="],
|
|
91
|
+
|
|
89
92
|
"is-extendable": ["is-extendable@0.1.1", "", {}, "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw=="],
|
|
90
93
|
|
|
91
94
|
"is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="],
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-think",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Personal context manager for Claude - manage your preferences, patterns, and memory",
|
|
5
5
|
"author": "Amit Feldman",
|
|
6
6
|
"license": "MIT",
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
"commander": "^14.0.3",
|
|
33
33
|
"gray-matter": "^4.0.3",
|
|
34
34
|
"ink": "^6.6.0",
|
|
35
|
+
"ink-text-input": "^6.0.0",
|
|
35
36
|
"react": "^19.2.4",
|
|
36
37
|
"string-similarity": "^4.0.4"
|
|
37
38
|
}
|
package/src/tui/App.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import React, { useState, useEffect } from "react";
|
|
2
|
-
import { Box, Text, useInput, useApp } from "ink";
|
|
2
|
+
import { Box, Text, useInput, useApp, useStdout } from "ink";
|
|
3
3
|
import { Navigation } from "./components/Navigation";
|
|
4
4
|
import { Profile } from "./components/Profile";
|
|
5
5
|
import { Preferences } from "./components/Preferences";
|
|
@@ -9,9 +9,14 @@ import { Skills } from "./components/Skills";
|
|
|
9
9
|
import { Agents } from "./components/Agents";
|
|
10
10
|
import { Automation } from "./components/Automation";
|
|
11
11
|
import { Help } from "./components/Help";
|
|
12
|
+
import { StatusBar } from "./components/StatusBar";
|
|
13
|
+
import { QuickActions } from "./components/QuickActions";
|
|
14
|
+
import { Preview } from "./components/Preview";
|
|
15
|
+
import { Search } from "./components/Search";
|
|
12
16
|
import { existsSync } from "fs";
|
|
13
17
|
import { CONFIG } from "../core/config";
|
|
14
18
|
|
|
19
|
+
// Note: "help" is handled as a modal, not a navigation section
|
|
15
20
|
type Section =
|
|
16
21
|
| "profile"
|
|
17
22
|
| "preferences"
|
|
@@ -19,39 +24,110 @@ type Section =
|
|
|
19
24
|
| "permissions"
|
|
20
25
|
| "skills"
|
|
21
26
|
| "agents"
|
|
22
|
-
| "automation"
|
|
23
|
-
|
|
27
|
+
| "automation";
|
|
28
|
+
|
|
29
|
+
type Modal = "none" | "help" | "actions" | "preview" | "search";
|
|
24
30
|
|
|
25
31
|
export function App() {
|
|
26
32
|
const { exit } = useApp();
|
|
33
|
+
const { stdout } = useStdout();
|
|
27
34
|
const [section, setSection] = useState<Section>("profile");
|
|
28
|
-
const [
|
|
35
|
+
const [modal, setModal] = useState<Modal>("none");
|
|
36
|
+
const [statusMessage, setStatusMessage] = useState<string | undefined>();
|
|
29
37
|
const [initialized, setInitialized] = useState(false);
|
|
30
38
|
|
|
39
|
+
// Get terminal dimensions
|
|
40
|
+
const width = stdout?.columns ?? 80;
|
|
41
|
+
const isNarrow = width < 80;
|
|
42
|
+
const isVeryNarrow = width < 50;
|
|
43
|
+
|
|
31
44
|
useEffect(() => {
|
|
32
45
|
setInitialized(existsSync(CONFIG.thinkDir));
|
|
33
46
|
}, []);
|
|
34
47
|
|
|
35
48
|
useInput((input, key) => {
|
|
49
|
+
// Global shortcuts (when no modal open)
|
|
50
|
+
if (modal !== "none") return;
|
|
51
|
+
|
|
36
52
|
if (input === "q" || (key.ctrl && input === "c")) {
|
|
37
53
|
exit();
|
|
38
54
|
}
|
|
39
55
|
if (input === "?") {
|
|
40
|
-
|
|
56
|
+
setModal("help");
|
|
57
|
+
}
|
|
58
|
+
if (key.ctrl && input === "s") {
|
|
59
|
+
setModal("actions");
|
|
60
|
+
}
|
|
61
|
+
if (input === "a") {
|
|
62
|
+
setModal("actions");
|
|
63
|
+
}
|
|
64
|
+
if (input === "p") {
|
|
65
|
+
setModal("preview");
|
|
66
|
+
}
|
|
67
|
+
if (input === "/" || (key.ctrl && input === "f")) {
|
|
68
|
+
setModal("search");
|
|
41
69
|
}
|
|
42
70
|
});
|
|
43
71
|
|
|
72
|
+
function handleMessage(msg: string) {
|
|
73
|
+
setStatusMessage(msg);
|
|
74
|
+
setTimeout(() => setStatusMessage(undefined), 3000);
|
|
75
|
+
}
|
|
76
|
+
|
|
44
77
|
if (!initialized) {
|
|
45
78
|
return (
|
|
46
79
|
<Box flexDirection="column" padding={1}>
|
|
47
|
-
<
|
|
48
|
-
|
|
80
|
+
<Box
|
|
81
|
+
borderStyle="round"
|
|
82
|
+
borderColor="red"
|
|
83
|
+
paddingX={2}
|
|
84
|
+
paddingY={1}
|
|
85
|
+
flexDirection="column"
|
|
86
|
+
>
|
|
87
|
+
<Text color="red" bold>
|
|
88
|
+
~/.think not found
|
|
89
|
+
</Text>
|
|
90
|
+
<Box marginTop={1}>
|
|
91
|
+
<Text color="gray">Run `think init` first, then launch the TUI.</Text>
|
|
92
|
+
</Box>
|
|
93
|
+
</Box>
|
|
94
|
+
</Box>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Render modals
|
|
99
|
+
if (modal === "help") {
|
|
100
|
+
return <Help onClose={() => setModal("none")} />;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (modal === "actions") {
|
|
104
|
+
return (
|
|
105
|
+
<Box flexDirection="column" padding={1}>
|
|
106
|
+
<Header width={width} />
|
|
107
|
+
<QuickActions
|
|
108
|
+
onMessage={handleMessage}
|
|
109
|
+
onClose={() => setModal("none")}
|
|
110
|
+
/>
|
|
49
111
|
</Box>
|
|
50
112
|
);
|
|
51
113
|
}
|
|
52
114
|
|
|
53
|
-
if (
|
|
54
|
-
return
|
|
115
|
+
if (modal === "preview") {
|
|
116
|
+
return (
|
|
117
|
+
<Box flexDirection="column" padding={1}>
|
|
118
|
+
<Header width={width} />
|
|
119
|
+
<Preview onClose={() => setModal("none")} />
|
|
120
|
+
</Box>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (modal === "search") {
|
|
125
|
+
return (
|
|
126
|
+
<Box flexDirection="column" padding={1}>
|
|
127
|
+
<Header width={width} />
|
|
128
|
+
<Search onClose={() => setModal("none")} />
|
|
129
|
+
</Box>
|
|
130
|
+
);
|
|
55
131
|
}
|
|
56
132
|
|
|
57
133
|
const renderSection = () => {
|
|
@@ -77,24 +153,54 @@ export function App() {
|
|
|
77
153
|
|
|
78
154
|
return (
|
|
79
155
|
<Box flexDirection="column">
|
|
80
|
-
<
|
|
81
|
-
<Text color="green" bold>
|
|
82
|
-
▀█▀ █░█ █ █▄░█ █▄▀
|
|
83
|
-
</Text>
|
|
84
|
-
<Text color="gray"> Personal Context for Claude</Text>
|
|
85
|
-
</Box>
|
|
156
|
+
<Header width={width} />
|
|
86
157
|
|
|
87
158
|
<Navigation currentSection={section} onSectionChange={setSection} />
|
|
88
159
|
|
|
89
|
-
<Box
|
|
160
|
+
<Box
|
|
161
|
+
flexDirection="column"
|
|
162
|
+
marginTop={1}
|
|
163
|
+
borderStyle="single"
|
|
164
|
+
borderColor="gray"
|
|
165
|
+
padding={1}
|
|
166
|
+
minHeight={15}
|
|
167
|
+
>
|
|
90
168
|
{renderSection()}
|
|
91
169
|
</Box>
|
|
92
170
|
|
|
171
|
+
<Box marginTop={1}>
|
|
172
|
+
<StatusBar message={statusMessage} />
|
|
173
|
+
</Box>
|
|
174
|
+
|
|
93
175
|
<Box marginTop={1}>
|
|
94
176
|
<Text color="gray">
|
|
95
|
-
|
|
177
|
+
{isNarrow
|
|
178
|
+
? "Tab:nav a:act p:prev /:search ?:help q:quit"
|
|
179
|
+
: "Tab: sections | a: actions | p: preview | /: search | ?: help | q: quit"}
|
|
96
180
|
</Text>
|
|
97
181
|
</Box>
|
|
98
182
|
</Box>
|
|
99
183
|
);
|
|
100
184
|
}
|
|
185
|
+
|
|
186
|
+
function Header({ width }: { width: number }) {
|
|
187
|
+
// Full banner needs ~45 chars, compact needs ~25
|
|
188
|
+
if (width >= 50) {
|
|
189
|
+
return (
|
|
190
|
+
<Box marginBottom={1}>
|
|
191
|
+
<Text color="green" bold>
|
|
192
|
+
▀█▀ █░█ █ █▄░█ █▄▀
|
|
193
|
+
</Text>
|
|
194
|
+
<Text color="gray"> Personal Context for Claude</Text>
|
|
195
|
+
</Box>
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Compact header for narrow terminals
|
|
200
|
+
return (
|
|
201
|
+
<Box marginBottom={1}>
|
|
202
|
+
<Text color="green" bold>think</Text>
|
|
203
|
+
<Text color="gray"> - Claude Context</Text>
|
|
204
|
+
</Box>
|
|
205
|
+
);
|
|
206
|
+
}
|
|
@@ -16,40 +16,70 @@ export function Help({ onClose }: HelpProps) {
|
|
|
16
16
|
<Box flexDirection="column" padding={1}>
|
|
17
17
|
<Box marginBottom={1}>
|
|
18
18
|
<Text bold color="green">
|
|
19
|
-
|
|
19
|
+
▀█▀ █░█ █ █▄░█ █▄▀
|
|
20
20
|
</Text>
|
|
21
|
+
<Text color="gray"> Help</Text>
|
|
21
22
|
</Box>
|
|
22
23
|
|
|
23
|
-
<Box
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
<
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
24
|
+
<Box
|
|
25
|
+
flexDirection="column"
|
|
26
|
+
borderStyle="single"
|
|
27
|
+
borderColor="gray"
|
|
28
|
+
padding={1}
|
|
29
|
+
>
|
|
30
|
+
<Box flexDirection="row">
|
|
31
|
+
<Box flexDirection="column" marginRight={4}>
|
|
32
|
+
<Text bold color="cyan">Navigation</Text>
|
|
33
|
+
<Text><Text color="green">Tab</Text> Switch sections</Text>
|
|
34
|
+
<Text><Text color="green">1-7</Text> Jump to section</Text>
|
|
35
|
+
<Text><Text color="green">←/→</Text> Sub-sections</Text>
|
|
36
|
+
<Text><Text color="green">↑/↓</Text> Select item</Text>
|
|
37
|
+
</Box>
|
|
38
|
+
|
|
39
|
+
<Box flexDirection="column" marginRight={4}>
|
|
40
|
+
<Text bold color="cyan">Quick Actions</Text>
|
|
41
|
+
<Text><Text color="green">a</Text> Actions menu</Text>
|
|
42
|
+
<Text><Text color="green">p</Text> Preview CLAUDE.md</Text>
|
|
43
|
+
<Text><Text color="green">/</Text> Search</Text>
|
|
44
|
+
<Text><Text color="green">Ctrl+S</Text> Sync</Text>
|
|
45
|
+
</Box>
|
|
46
|
+
|
|
47
|
+
<Box flexDirection="column">
|
|
48
|
+
<Text bold color="cyan">Editing</Text>
|
|
49
|
+
<Text><Text color="green">e</Text> Edit in $EDITOR</Text>
|
|
50
|
+
<Text><Text color="green">n</Text> Create new item</Text>
|
|
51
|
+
<Text><Text color="green">Enter</Text> Select/Open</Text>
|
|
52
|
+
<Text><Text color="green">Esc</Text> Close modal</Text>
|
|
53
|
+
</Box>
|
|
54
|
+
</Box>
|
|
55
|
+
|
|
56
|
+
<Box marginTop={1} flexDirection="column">
|
|
57
|
+
<Text bold color="cyan">CLI Commands</Text>
|
|
58
|
+
<Box flexDirection="row">
|
|
59
|
+
<Box flexDirection="column" marginRight={2}>
|
|
60
|
+
<Text color="gray">think init</Text>
|
|
61
|
+
<Text color="gray">think setup</Text>
|
|
62
|
+
<Text color="gray">think sync</Text>
|
|
63
|
+
<Text color="gray">think status</Text>
|
|
64
|
+
</Box>
|
|
65
|
+
<Box flexDirection="column" marginRight={2}>
|
|
66
|
+
<Text color="gray">think learn "..."</Text>
|
|
67
|
+
<Text color="gray">think review</Text>
|
|
68
|
+
<Text color="gray">think profile</Text>
|
|
69
|
+
<Text color="gray">think edit <file></Text>
|
|
70
|
+
</Box>
|
|
71
|
+
<Box flexDirection="column">
|
|
72
|
+
<Text color="gray">think allow "cmd"</Text>
|
|
73
|
+
<Text color="gray">think tree</Text>
|
|
74
|
+
<Text color="gray">think project learn</Text>
|
|
75
|
+
<Text color="gray">think help</Text>
|
|
76
|
+
</Box>
|
|
77
|
+
</Box>
|
|
78
|
+
</Box>
|
|
49
79
|
</Box>
|
|
50
80
|
|
|
51
81
|
<Box marginTop={1}>
|
|
52
|
-
<Text color="gray">Press ? or
|
|
82
|
+
<Text color="gray">Press ? or Esc to close</Text>
|
|
53
83
|
</Box>
|
|
54
84
|
</Box>
|
|
55
85
|
);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import React from "react";
|
|
2
|
-
import { Box, Text, useInput } from "ink";
|
|
2
|
+
import { Box, Text, useInput, useStdout } from "ink";
|
|
3
3
|
|
|
4
4
|
type Section =
|
|
5
5
|
| "profile"
|
|
@@ -15,38 +15,76 @@ interface NavigationProps {
|
|
|
15
15
|
onSectionChange: (section: Section) => void;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
const sections: { key: Section; label: string }[] = [
|
|
19
|
-
{ key: "profile", label: "Profile" },
|
|
20
|
-
{ key: "preferences", label: "Preferences" },
|
|
21
|
-
{ key: "memory", label: "Memory" },
|
|
22
|
-
{ key: "permissions", label: "Permissions" },
|
|
23
|
-
{ key: "skills", label: "Skills" },
|
|
24
|
-
{ key: "agents", label: "Agents" },
|
|
25
|
-
{ key: "automation", label: "Automation" },
|
|
18
|
+
const sections: { key: Section; label: string; short: string }[] = [
|
|
19
|
+
{ key: "profile", label: "Profile", short: "Prof" },
|
|
20
|
+
{ key: "preferences", label: "Preferences", short: "Pref" },
|
|
21
|
+
{ key: "memory", label: "Memory", short: "Mem" },
|
|
22
|
+
{ key: "permissions", label: "Permissions", short: "Perm" },
|
|
23
|
+
{ key: "skills", label: "Skills", short: "Skill" },
|
|
24
|
+
{ key: "agents", label: "Agents", short: "Agent" },
|
|
25
|
+
{ key: "automation", label: "Automation", short: "Auto" },
|
|
26
26
|
];
|
|
27
27
|
|
|
28
28
|
export function Navigation({
|
|
29
29
|
currentSection,
|
|
30
30
|
onSectionChange,
|
|
31
31
|
}: NavigationProps) {
|
|
32
|
+
const { stdout } = useStdout();
|
|
33
|
+
const width = stdout?.columns ?? 80;
|
|
34
|
+
const isNarrow = width < 80;
|
|
35
|
+
const isVeryNarrow = width < 50;
|
|
36
|
+
|
|
32
37
|
useInput((input, key) => {
|
|
33
38
|
if (key.tab) {
|
|
34
39
|
const currentIndex = sections.findIndex((s) => s.key === currentSection);
|
|
35
40
|
const nextIndex = key.shift
|
|
36
41
|
? (currentIndex - 1 + sections.length) % sections.length
|
|
37
42
|
: (currentIndex + 1) % sections.length;
|
|
38
|
-
onSectionChange(sections[nextIndex]
|
|
43
|
+
onSectionChange(sections[nextIndex]!.key);
|
|
39
44
|
}
|
|
40
45
|
|
|
41
46
|
// Number keys for quick navigation
|
|
42
47
|
const num = parseInt(input);
|
|
43
48
|
if (num >= 1 && num <= sections.length) {
|
|
44
|
-
onSectionChange(sections[num - 1]
|
|
49
|
+
onSectionChange(sections[num - 1]!.key);
|
|
45
50
|
}
|
|
46
51
|
});
|
|
47
52
|
|
|
53
|
+
// Very narrow: show only current + numbers
|
|
54
|
+
if (isVeryNarrow) {
|
|
55
|
+
const currentIdx = sections.findIndex((s) => s.key === currentSection);
|
|
56
|
+
const current = sections[currentIdx]!;
|
|
57
|
+
return (
|
|
58
|
+
<Box>
|
|
59
|
+
<Text color="green" bold>
|
|
60
|
+
[{currentIdx + 1}/{sections.length}] {current.label}
|
|
61
|
+
</Text>
|
|
62
|
+
<Text color="gray"> (Tab/1-7)</Text>
|
|
63
|
+
</Box>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Narrow: use short labels
|
|
68
|
+
if (isNarrow) {
|
|
69
|
+
return (
|
|
70
|
+
<Box flexWrap="wrap">
|
|
71
|
+
{sections.map((section, index) => (
|
|
72
|
+
<Box key={section.key} marginRight={1}>
|
|
73
|
+
<Text
|
|
74
|
+
color={currentSection === section.key ? "green" : "gray"}
|
|
75
|
+
bold={currentSection === section.key}
|
|
76
|
+
>
|
|
77
|
+
{index + 1}.{section.short}
|
|
78
|
+
</Text>
|
|
79
|
+
</Box>
|
|
80
|
+
))}
|
|
81
|
+
</Box>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Full width: show full labels with indicator
|
|
48
86
|
return (
|
|
49
|
-
<Box>
|
|
87
|
+
<Box flexWrap="wrap">
|
|
50
88
|
{sections.map((section, index) => (
|
|
51
89
|
<Box key={section.key} marginRight={1}>
|
|
52
90
|
<Text
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import React, { useState, useEffect } from "react";
|
|
2
|
+
import { Box, Text, useInput } from "ink";
|
|
3
|
+
import { readFile } from "fs/promises";
|
|
4
|
+
import { existsSync } from "fs";
|
|
5
|
+
import { CONFIG } from "../../core/config";
|
|
6
|
+
|
|
7
|
+
interface PreviewProps {
|
|
8
|
+
onClose: () => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function Preview({ onClose }: PreviewProps) {
|
|
12
|
+
const [content, setContent] = useState<string[]>([]);
|
|
13
|
+
const [scroll, setScroll] = useState(0);
|
|
14
|
+
const [loading, setLoading] = useState(true);
|
|
15
|
+
const maxLines = 20;
|
|
16
|
+
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
loadPreview();
|
|
19
|
+
}, []);
|
|
20
|
+
|
|
21
|
+
async function loadPreview() {
|
|
22
|
+
if (existsSync(CONFIG.claudeMdPath)) {
|
|
23
|
+
const text = await readFile(CONFIG.claudeMdPath, "utf-8");
|
|
24
|
+
setContent(text.split("\n"));
|
|
25
|
+
}
|
|
26
|
+
setLoading(false);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
useInput((input, key) => {
|
|
30
|
+
if (key.escape || input === "q") {
|
|
31
|
+
onClose();
|
|
32
|
+
}
|
|
33
|
+
if (key.upArrow || input === "k") {
|
|
34
|
+
setScroll((s) => Math.max(0, s - 1));
|
|
35
|
+
}
|
|
36
|
+
if (key.downArrow || input === "j") {
|
|
37
|
+
setScroll((s) => Math.min(content.length - maxLines, s + 1));
|
|
38
|
+
}
|
|
39
|
+
if (key.pageUp) {
|
|
40
|
+
setScroll((s) => Math.max(0, s - maxLines));
|
|
41
|
+
}
|
|
42
|
+
if (key.pageDown) {
|
|
43
|
+
setScroll((s) => Math.min(content.length - maxLines, s + maxLines));
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
if (loading) {
|
|
48
|
+
return <Text color="gray">Loading preview...</Text>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const visibleLines = content.slice(scroll, scroll + maxLines);
|
|
52
|
+
const scrollPercent = content.length > maxLines
|
|
53
|
+
? Math.round((scroll / (content.length - maxLines)) * 100)
|
|
54
|
+
: 100;
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<Box flexDirection="column" borderStyle="single" borderColor="cyan" padding={1}>
|
|
58
|
+
<Box marginBottom={1}>
|
|
59
|
+
<Text bold color="cyan">CLAUDE.md Preview</Text>
|
|
60
|
+
<Text color="gray"> ({content.length} lines)</Text>
|
|
61
|
+
<Box flexGrow={1} />
|
|
62
|
+
<Text color="gray">{scrollPercent}%</Text>
|
|
63
|
+
</Box>
|
|
64
|
+
|
|
65
|
+
<Box flexDirection="column" height={maxLines}>
|
|
66
|
+
{visibleLines.map((line, i) => {
|
|
67
|
+
let color: string | undefined;
|
|
68
|
+
if (line.startsWith("# ")) color = "green";
|
|
69
|
+
else if (line.startsWith("## ")) color = "yellow";
|
|
70
|
+
else if (line.startsWith("- ")) color = "white";
|
|
71
|
+
else if (line.startsWith("**")) color = "cyan";
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<Text key={scroll + i} color={color} dimColor={!color}>
|
|
75
|
+
{line || " "}
|
|
76
|
+
</Text>
|
|
77
|
+
);
|
|
78
|
+
})}
|
|
79
|
+
</Box>
|
|
80
|
+
|
|
81
|
+
<Box marginTop={1}>
|
|
82
|
+
<Text color="gray">↑↓/jk: scroll | PgUp/PgDn: page | Esc: close</Text>
|
|
83
|
+
</Box>
|
|
84
|
+
</Box>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import React, { useState } from "react";
|
|
2
|
+
import { Box, Text, useInput } from "ink";
|
|
3
|
+
import TextInput from "ink-text-input";
|
|
4
|
+
import { generatePlugin } from "../../core/generator";
|
|
5
|
+
import { addLearning } from "../../core/dedup";
|
|
6
|
+
import { readFile, writeFile } from "fs/promises";
|
|
7
|
+
import { existsSync } from "fs";
|
|
8
|
+
import { thinkPath, CONFIG } from "../../core/config";
|
|
9
|
+
|
|
10
|
+
interface QuickActionsProps {
|
|
11
|
+
onMessage: (msg: string) => void;
|
|
12
|
+
onClose: () => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
type Action = "menu" | "sync" | "learn" | "search";
|
|
16
|
+
|
|
17
|
+
export function QuickActions({ onMessage, onClose }: QuickActionsProps) {
|
|
18
|
+
const [action, setAction] = useState<Action>("menu");
|
|
19
|
+
const [input, setInput] = useState("");
|
|
20
|
+
const [selected, setSelected] = useState(0);
|
|
21
|
+
|
|
22
|
+
const menuItems = [
|
|
23
|
+
{ key: "s", label: "Sync", desc: "Regenerate CLAUDE.md" },
|
|
24
|
+
{ key: "l", label: "Learn", desc: "Add a new learning" },
|
|
25
|
+
{ key: "f", label: "Search", desc: "Search all files" },
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
useInput((key, mod) => {
|
|
29
|
+
if (mod.escape || key === "q") {
|
|
30
|
+
if (action === "menu") {
|
|
31
|
+
onClose();
|
|
32
|
+
} else {
|
|
33
|
+
setAction("menu");
|
|
34
|
+
setInput("");
|
|
35
|
+
}
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (action === "menu") {
|
|
40
|
+
if (mod.upArrow || key === "k") {
|
|
41
|
+
setSelected((s) => (s - 1 + menuItems.length) % menuItems.length);
|
|
42
|
+
}
|
|
43
|
+
if (mod.downArrow || key === "j") {
|
|
44
|
+
setSelected((s) => (s + 1) % menuItems.length);
|
|
45
|
+
}
|
|
46
|
+
if (mod.return) {
|
|
47
|
+
const item = menuItems[selected]!;
|
|
48
|
+
if (item.key === "s") handleSync();
|
|
49
|
+
else if (item.key === "l") setAction("learn");
|
|
50
|
+
else if (item.key === "f") setAction("search");
|
|
51
|
+
}
|
|
52
|
+
// Quick keys
|
|
53
|
+
if (key === "s") handleSync();
|
|
54
|
+
if (key === "l") setAction("learn");
|
|
55
|
+
if (key === "f") setAction("search");
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
async function handleSync() {
|
|
60
|
+
onMessage("Syncing...");
|
|
61
|
+
try {
|
|
62
|
+
await generatePlugin();
|
|
63
|
+
onMessage("Synced successfully!");
|
|
64
|
+
} catch (e) {
|
|
65
|
+
onMessage("Sync failed!");
|
|
66
|
+
}
|
|
67
|
+
setTimeout(onClose, 1500);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function handleLearn() {
|
|
71
|
+
if (!input.trim()) return;
|
|
72
|
+
|
|
73
|
+
const learningsPath = thinkPath(CONFIG.files.learnings);
|
|
74
|
+
let content = "";
|
|
75
|
+
if (existsSync(learningsPath)) {
|
|
76
|
+
content = await readFile(learningsPath, "utf-8");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const result = addLearning(content, input.trim());
|
|
80
|
+
if (result.added) {
|
|
81
|
+
await writeFile(learningsPath, result.newContent);
|
|
82
|
+
onMessage(`Added: "${input.trim()}"`);
|
|
83
|
+
await generatePlugin();
|
|
84
|
+
} else {
|
|
85
|
+
onMessage(`Similar exists: "${result.similar}"`);
|
|
86
|
+
}
|
|
87
|
+
setInput("");
|
|
88
|
+
setTimeout(onClose, 1500);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (action === "menu") {
|
|
92
|
+
return (
|
|
93
|
+
<Box flexDirection="column" borderStyle="round" borderColor="green" padding={1}>
|
|
94
|
+
<Text bold color="green">Quick Actions</Text>
|
|
95
|
+
<Box flexDirection="column" marginTop={1}>
|
|
96
|
+
{menuItems.map((item, i) => (
|
|
97
|
+
<Box key={item.key}>
|
|
98
|
+
<Text color={i === selected ? "green" : "gray"}>
|
|
99
|
+
{i === selected ? "▸ " : " "}
|
|
100
|
+
</Text>
|
|
101
|
+
<Text color={i === selected ? "white" : "gray"} bold={i === selected}>
|
|
102
|
+
[{item.key}] {item.label}
|
|
103
|
+
</Text>
|
|
104
|
+
<Text color="gray"> - {item.desc}</Text>
|
|
105
|
+
</Box>
|
|
106
|
+
))}
|
|
107
|
+
</Box>
|
|
108
|
+
<Box marginTop={1}>
|
|
109
|
+
<Text color="gray">↑↓: navigate | Enter: select | Esc: close</Text>
|
|
110
|
+
</Box>
|
|
111
|
+
</Box>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (action === "learn") {
|
|
116
|
+
return (
|
|
117
|
+
<Box flexDirection="column" borderStyle="round" borderColor="yellow" padding={1}>
|
|
118
|
+
<Text bold color="yellow">Add Learning</Text>
|
|
119
|
+
<Box marginTop={1}>
|
|
120
|
+
<Text color="gray">▸ </Text>
|
|
121
|
+
<TextInput
|
|
122
|
+
value={input}
|
|
123
|
+
onChange={setInput}
|
|
124
|
+
onSubmit={handleLearn}
|
|
125
|
+
placeholder="Type your learning..."
|
|
126
|
+
/>
|
|
127
|
+
</Box>
|
|
128
|
+
<Box marginTop={1}>
|
|
129
|
+
<Text color="gray">Enter: add | Esc: cancel</Text>
|
|
130
|
+
</Box>
|
|
131
|
+
</Box>
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (action === "search") {
|
|
136
|
+
return (
|
|
137
|
+
<Box flexDirection="column" borderStyle="round" borderColor="cyan" padding={1}>
|
|
138
|
+
<Text bold color="cyan">Search</Text>
|
|
139
|
+
<Box marginTop={1}>
|
|
140
|
+
<Text color="gray">▸ </Text>
|
|
141
|
+
<TextInput
|
|
142
|
+
value={input}
|
|
143
|
+
onChange={setInput}
|
|
144
|
+
placeholder="Search term..."
|
|
145
|
+
/>
|
|
146
|
+
</Box>
|
|
147
|
+
<Box marginTop={1}>
|
|
148
|
+
<Text color="gray">Enter: search | Esc: cancel</Text>
|
|
149
|
+
</Box>
|
|
150
|
+
</Box>
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import React, { useState, useEffect } from "react";
|
|
2
|
+
import { Box, Text, useInput } from "ink";
|
|
3
|
+
import TextInput from "ink-text-input";
|
|
4
|
+
import { readFile } from "fs/promises";
|
|
5
|
+
import { existsSync } from "fs";
|
|
6
|
+
import { thinkPath, CONFIG } from "../../core/config";
|
|
7
|
+
|
|
8
|
+
interface SearchProps {
|
|
9
|
+
onClose: () => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface SearchResult {
|
|
13
|
+
file: string;
|
|
14
|
+
line: number;
|
|
15
|
+
content: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const searchFiles = [
|
|
19
|
+
{ name: "profile", path: CONFIG.files.profile },
|
|
20
|
+
{ name: "tools", path: CONFIG.files.tools },
|
|
21
|
+
{ name: "patterns", path: CONFIG.files.patterns },
|
|
22
|
+
{ name: "anti-patterns", path: CONFIG.files.antiPatterns },
|
|
23
|
+
{ name: "learnings", path: CONFIG.files.learnings },
|
|
24
|
+
{ name: "corrections", path: CONFIG.files.corrections },
|
|
25
|
+
{ name: "subagents", path: CONFIG.files.subagents },
|
|
26
|
+
{ name: "workflows", path: CONFIG.files.workflows },
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
export function Search({ onClose }: SearchProps) {
|
|
30
|
+
const [query, setQuery] = useState("");
|
|
31
|
+
const [results, setResults] = useState<SearchResult[]>([]);
|
|
32
|
+
const [selected, setSelected] = useState(0);
|
|
33
|
+
const [searching, setSearching] = useState(false);
|
|
34
|
+
|
|
35
|
+
useInput((input, key) => {
|
|
36
|
+
if (key.escape) {
|
|
37
|
+
onClose();
|
|
38
|
+
}
|
|
39
|
+
if (key.upArrow || (key.ctrl && input === "p")) {
|
|
40
|
+
setSelected((s) => Math.max(0, s - 1));
|
|
41
|
+
}
|
|
42
|
+
if (key.downArrow || (key.ctrl && input === "n")) {
|
|
43
|
+
setSelected((s) => Math.min(results.length - 1, s + 1));
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
async function handleSearch(q: string) {
|
|
48
|
+
setQuery(q);
|
|
49
|
+
if (q.length < 2) {
|
|
50
|
+
setResults([]);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
setSearching(true);
|
|
55
|
+
const found: SearchResult[] = [];
|
|
56
|
+
const lowerQuery = q.toLowerCase();
|
|
57
|
+
|
|
58
|
+
for (const file of searchFiles) {
|
|
59
|
+
const fullPath = thinkPath(file.path);
|
|
60
|
+
if (!existsSync(fullPath)) continue;
|
|
61
|
+
|
|
62
|
+
const content = await readFile(fullPath, "utf-8");
|
|
63
|
+
const lines = content.split("\n");
|
|
64
|
+
|
|
65
|
+
lines.forEach((line, i) => {
|
|
66
|
+
if (line.toLowerCase().includes(lowerQuery)) {
|
|
67
|
+
found.push({
|
|
68
|
+
file: file.name,
|
|
69
|
+
line: i + 1,
|
|
70
|
+
content: line.trim(),
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
setResults(found.slice(0, 15));
|
|
77
|
+
setSelected(0);
|
|
78
|
+
setSearching(false);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<Box flexDirection="column" borderStyle="round" borderColor="cyan" padding={1}>
|
|
83
|
+
<Box marginBottom={1}>
|
|
84
|
+
<Text bold color="cyan">Search</Text>
|
|
85
|
+
{results.length > 0 && (
|
|
86
|
+
<Text color="gray"> ({results.length} results)</Text>
|
|
87
|
+
)}
|
|
88
|
+
</Box>
|
|
89
|
+
|
|
90
|
+
<Box marginBottom={1}>
|
|
91
|
+
<Text color="cyan">▸ </Text>
|
|
92
|
+
<TextInput
|
|
93
|
+
value={query}
|
|
94
|
+
onChange={handleSearch}
|
|
95
|
+
placeholder="Type to search..."
|
|
96
|
+
/>
|
|
97
|
+
</Box>
|
|
98
|
+
|
|
99
|
+
{searching && <Text color="gray">Searching...</Text>}
|
|
100
|
+
|
|
101
|
+
{results.length > 0 && (
|
|
102
|
+
<Box flexDirection="column" marginTop={1}>
|
|
103
|
+
{results.map((r, i) => (
|
|
104
|
+
<Box key={`${r.file}-${r.line}`}>
|
|
105
|
+
<Text color={i === selected ? "cyan" : "gray"}>
|
|
106
|
+
{i === selected ? "▸ " : " "}
|
|
107
|
+
</Text>
|
|
108
|
+
<Text color="yellow">{r.file}</Text>
|
|
109
|
+
<Text color="gray">:{r.line} </Text>
|
|
110
|
+
<Text color={i === selected ? "white" : "gray"}>
|
|
111
|
+
{r.content.length > 50
|
|
112
|
+
? r.content.substring(0, 50) + "..."
|
|
113
|
+
: r.content}
|
|
114
|
+
</Text>
|
|
115
|
+
</Box>
|
|
116
|
+
))}
|
|
117
|
+
</Box>
|
|
118
|
+
)}
|
|
119
|
+
|
|
120
|
+
{query.length >= 2 && results.length === 0 && !searching && (
|
|
121
|
+
<Text color="gray">No results found</Text>
|
|
122
|
+
)}
|
|
123
|
+
|
|
124
|
+
<Box marginTop={1}>
|
|
125
|
+
<Text color="gray">↑↓: navigate | Esc: close</Text>
|
|
126
|
+
</Box>
|
|
127
|
+
</Box>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import React, { useState, useEffect } from "react";
|
|
2
|
+
import { Box, Text, useStdout } from "ink";
|
|
3
|
+
import { existsSync, statSync } from "fs";
|
|
4
|
+
import { readFile } from "fs/promises";
|
|
5
|
+
import { CONFIG, thinkPath } from "../../core/config";
|
|
6
|
+
import { extractLearnings } from "../../core/dedup";
|
|
7
|
+
|
|
8
|
+
interface StatusBarProps {
|
|
9
|
+
message?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function StatusBar({ message }: StatusBarProps) {
|
|
13
|
+
const [pendingCount, setPendingCount] = useState(0);
|
|
14
|
+
const [learningsCount, setLearningsCount] = useState(0);
|
|
15
|
+
const [lastSync, setLastSync] = useState<string | null>(null);
|
|
16
|
+
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
loadStatus();
|
|
19
|
+
const interval = setInterval(loadStatus, 5000);
|
|
20
|
+
return () => clearInterval(interval);
|
|
21
|
+
}, []);
|
|
22
|
+
|
|
23
|
+
async function loadStatus() {
|
|
24
|
+
// Count pending
|
|
25
|
+
const pendingPath = thinkPath(CONFIG.files.pending);
|
|
26
|
+
if (existsSync(pendingPath)) {
|
|
27
|
+
const content = await readFile(pendingPath, "utf-8");
|
|
28
|
+
setPendingCount(extractLearnings(content).length);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Count learnings
|
|
32
|
+
const learningsPath = thinkPath(CONFIG.files.learnings);
|
|
33
|
+
if (existsSync(learningsPath)) {
|
|
34
|
+
const content = await readFile(learningsPath, "utf-8");
|
|
35
|
+
setLearningsCount(extractLearnings(content).length);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Last sync time
|
|
39
|
+
if (existsSync(CONFIG.claudeMdPath)) {
|
|
40
|
+
const stats = statSync(CONFIG.claudeMdPath);
|
|
41
|
+
const ago = formatTimeAgo(stats.mtime);
|
|
42
|
+
setLastSync(ago);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const { stdout } = useStdout();
|
|
47
|
+
const width = stdout?.columns ?? 80;
|
|
48
|
+
const isNarrow = width < 60;
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<Box borderStyle="single" borderColor="gray" paddingX={1}>
|
|
52
|
+
<Box flexGrow={1}>
|
|
53
|
+
{message ? (
|
|
54
|
+
<Text color="yellow">{message}</Text>
|
|
55
|
+
) : (
|
|
56
|
+
<Text color="gray">Ready</Text>
|
|
57
|
+
)}
|
|
58
|
+
</Box>
|
|
59
|
+
<Box marginLeft={1}>
|
|
60
|
+
<Text color="green">{learningsCount}</Text>
|
|
61
|
+
<Text color="gray">{isNarrow ? "L" : " learnings"}</Text>
|
|
62
|
+
</Box>
|
|
63
|
+
{pendingCount > 0 && (
|
|
64
|
+
<Box marginLeft={1}>
|
|
65
|
+
<Text color="yellow">{pendingCount}</Text>
|
|
66
|
+
<Text color="gray">{isNarrow ? "P" : " pending"}</Text>
|
|
67
|
+
</Box>
|
|
68
|
+
)}
|
|
69
|
+
{lastSync && !isNarrow && (
|
|
70
|
+
<Box marginLeft={1}>
|
|
71
|
+
<Text color="gray">synced {lastSync}</Text>
|
|
72
|
+
</Box>
|
|
73
|
+
)}
|
|
74
|
+
</Box>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function formatTimeAgo(date: Date): string {
|
|
79
|
+
const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
|
|
80
|
+
if (seconds < 60) return "just now";
|
|
81
|
+
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
|
|
82
|
+
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
|
|
83
|
+
return `${Math.floor(seconds / 86400)}d ago`;
|
|
84
|
+
}
|