@meanc/otter 0.0.1 → 0.0.3

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 CHANGED
@@ -3,6 +3,11 @@
3
3
  Otter (ot) 是一个基于 [Mihomo](https://github.com/MetaCubeX/mihomo)
4
4
  核心的极简主义 Clash TUI
5
5
  客户端。它专为速度和可组合性而设计,提供流畅的命令行体验和交互式界面。
6
+ <img width="1036" height="532" alt="image" src="https://github.com/user-attachments/assets/3d2969ed-41ee-4bd3-b37a-97173bea2d50" />
7
+
8
+ <img width="658" height="426" alt="image" src="https://github.com/user-attachments/assets/e82dfb1a-3548-45b4-be99-6f4a3bf1ea43" />
9
+
10
+
6
11
 
7
12
  ## ✨ 特性
8
13
 
package/bun.lock CHANGED
@@ -10,6 +10,7 @@
10
10
  "chalk": "^5.6.2",
11
11
  "fs-extra": "^11.3.3",
12
12
  "ink": "^6.5.1",
13
+ "ink-select-input": "^6.2.0",
13
14
  "js-yaml": "^4.1.1",
14
15
  "ps-list": "^9.0.0",
15
16
  "react": "^19.2.3",
@@ -77,6 +78,8 @@
77
78
 
78
79
  "escape-string-regexp": ["escape-string-regexp@2.0.0", "", {}, "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w=="],
79
80
 
81
+ "figures": ["figures@6.1.0", "", { "dependencies": { "is-unicode-supported": "^2.0.0" } }, "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg=="],
82
+
80
83
  "fs-extra": ["fs-extra@11.3.3", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg=="],
81
84
 
82
85
  "get-east-asian-width": ["get-east-asian-width@1.4.0", "", {}, "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q=="],
@@ -87,10 +90,14 @@
87
90
 
88
91
  "ink": ["ink@6.5.1", "", { "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-wF3j/DmkM8q5E+OtfdQhCRw8/0ahkc8CUTgEddxZzpEWPslu7YPL3t64MWRoI9m6upVGpfAg4ms2BBvxCdKRLQ=="],
89
92
 
93
+ "ink-select-input": ["ink-select-input@6.2.0", "", { "dependencies": { "figures": "^6.1.0", "to-rotated": "^1.0.0" }, "peerDependencies": { "ink": ">=5.0.0", "react": ">=18.0.0" } }, "sha512-304fZXxkpYxJ9si5lxRCaX01GNlmPBgOZumXXRnPYbHW/iI31cgQynqk2tRypGLOF1cMIwPUzL2LSm6q4I5rQQ=="],
94
+
90
95
  "is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="],
91
96
 
92
97
  "is-in-ci": ["is-in-ci@2.0.0", "", { "bin": { "is-in-ci": "cli.js" } }, "sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w=="],
93
98
 
99
+ "is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="],
100
+
94
101
  "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
95
102
 
96
103
  "jsonfile": ["jsonfile@6.2.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="],
@@ -121,6 +128,8 @@
121
128
 
122
129
  "strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
123
130
 
131
+ "to-rotated": ["to-rotated@1.0.0", "", {}, "sha512-KsEID8AfgUy+pxVRLsWp0VzCa69wxzUDZnzGbyIST/bcgcrMvTYoFBX/QORH4YApoD89EDuUovx4BTdpOn319Q=="],
132
+
124
133
  "type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="],
125
134
 
126
135
  "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
package/index.ts CHANGED
@@ -6,15 +6,26 @@ import * as proxy from './src/commands/proxy';
6
6
  import * as system from './src/commands/system';
7
7
  import * as ui from './src/commands/ui';
8
8
  import * as subscribe from './src/commands/subscribe';
9
+ import * as menu from './src/commands/menu';
10
+ import * as test from './src/commands/test';
11
+ import * as connections from './src/commands/connections';
12
+ import * as rules from './src/commands/rules';
13
+ import * as smart from './src/commands/smart';
9
14
  import { BIN_PATH } from './src/utils/paths';
10
15
 
11
16
  const cli = cac('ot');
12
17
 
13
18
  // Core
14
- cli.command('up', 'Start Clash core').alias('start').action(core.start);
19
+ cli.command('up', 'Start Clash core').alias('start').option('--smart', 'Enable smart mode').action(core.start);
15
20
  cli.command('down', 'Stop Clash core').alias('stop').action(core.stop);
16
21
  cli.command('status', 'Check status').action(core.status);
17
22
  cli.command('log', 'Show logs').action(core.log);
23
+ cli.command('conns', 'Manage connections').alias('connections').action(connections.show);
24
+ cli.command('match <url>', 'Check which rule matches the URL').action(rules.match);
25
+ cli.command('smart', 'Start Smart Pilot mode').action(smart.start);
26
+
27
+ // Test
28
+ cli.command('test [group]', 'Speed test proxies').action(test.test);
18
29
 
19
30
  // Subscribe
20
31
  cli.command('sub <cmd> [arg1] [arg2]', 'Manage subscriptions')
@@ -59,7 +70,7 @@ cli.command('use [node]', 'Switch node')
59
70
  .option('-g, --global <index>', 'Select by global index')
60
71
  .option('-p, --proxy <index>', 'Select by proxy index')
61
72
  .action(proxy.use);
62
- cli.command('test', 'Test latency').action(proxy.test);
73
+ // cli.command('test', 'Test latency').action(proxy.test); // Replaced by src/commands/test.ts
63
74
  cli.command('best', 'Select best node').action(proxy.best);
64
75
 
65
76
  // System
@@ -95,11 +106,11 @@ cli.help((sections) => {
95
106
 
96
107
  cli.commands.forEach(cmd => {
97
108
  const name = cmd.name.split(' ')[0] || '';
98
- if (['up', 'down', 'status', 'log', 'start', 'stop'].includes(name)) {
109
+ if (['up', 'down', 'status', 'log', 'start', 'stop', 'conns'].includes(name)) {
99
110
  groups['Core Commands'].push(cmd);
100
111
  } else if (name === 'sub') {
101
112
  groups['Subscription Commands'].push(cmd);
102
- } else if (['ls', 'use', 'test', 'best'].includes(name)) {
113
+ } else if (['ls', 'use', 'test', 'best', 'match', 'smart'].includes(name)) {
103
114
  groups['Proxy Commands'].push(cmd);
104
115
  } else if (['on', 'off', 'shell', 'mode'].includes(name)) {
105
116
  groups['System Commands'].push(cmd);
@@ -138,4 +149,30 @@ cli.help((sections) => {
138
149
  return newSections;
139
150
  });
140
151
 
141
- cli.parse();
152
+ const run = async () => {
153
+ // If no args provided, show menu
154
+ if (process.argv.length === 2) {
155
+ const action = await menu.show();
156
+ switch (action) {
157
+ case 'start':
158
+ await core.start();
159
+ break;
160
+ case 'stop':
161
+ await core.stop();
162
+ break;
163
+ case 'status':
164
+ await core.status();
165
+ break;
166
+ case 'ui':
167
+ await ui.ui();
168
+ break;
169
+ case 'quit':
170
+ process.exit(0);
171
+ break;
172
+ }
173
+ } else {
174
+ cli.parse();
175
+ }
176
+ };
177
+
178
+ run();
package/package.json CHANGED
@@ -5,7 +5,7 @@
5
5
  },
6
6
  "description": "以水獭为名的clash tui",
7
7
  "module": "index.ts",
8
- "version": "0.0.1",
8
+ "version": "0.0.3",
9
9
  "bin": {
10
10
  "ot": "index.ts"
11
11
  },
@@ -28,6 +28,7 @@
28
28
  "chalk": "^5.6.2",
29
29
  "fs-extra": "^11.3.3",
30
30
  "ink": "^6.5.1",
31
+ "ink-select-input": "^6.2.0",
31
32
  "js-yaml": "^4.1.1",
32
33
  "ps-list": "^9.0.0",
33
34
  "react": "^19.2.3",
@@ -0,0 +1,147 @@
1
+ import React, { useState, useEffect } from 'react';
2
+ import { render, Box, Text, useInput, useApp } from 'ink';
3
+ import { ClashAPI } from '../utils/api';
4
+
5
+ const formatSize = (bytes: number) => {
6
+ if (bytes === 0) return '0 B';
7
+ const k = 1024;
8
+ const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
9
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
10
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
11
+ };
12
+
13
+ const ConnectionsApp = () => {
14
+ const { exit } = useApp();
15
+ const [connections, setConnections] = useState<any[]>([]);
16
+ const [info, setInfo] = useState<any>({});
17
+ const [selectedIndex, setSelectedIndex] = useState(0);
18
+ const [message, setMessage] = useState('');
19
+
20
+ const refresh = async () => {
21
+ const data = await ClashAPI.getConnections();
22
+ if (data) {
23
+ // Sort by start time (newest first)
24
+ const sorted = (data.connections || []).sort((a: any, b: any) => {
25
+ return new Date(b.start).getTime() - new Date(a.start).getTime();
26
+ });
27
+ setConnections(sorted);
28
+ setInfo({
29
+ downloadTotal: data.downloadTotal,
30
+ uploadTotal: data.uploadTotal,
31
+ });
32
+ }
33
+ };
34
+
35
+ useEffect(() => {
36
+ refresh();
37
+ const interval = setInterval(refresh, 1000);
38
+ return () => clearInterval(interval);
39
+ }, []);
40
+
41
+ useInput(async (input, key) => {
42
+ if (input === 'q' || key.escape) {
43
+ exit();
44
+ return;
45
+ }
46
+
47
+ if (key.upArrow) {
48
+ setSelectedIndex(prev => Math.max(0, prev - 1));
49
+ }
50
+ if (key.downArrow) {
51
+ setSelectedIndex(prev => Math.min(connections.length - 1, prev + 1));
52
+ }
53
+
54
+ if (input === 'x') {
55
+ if (connections.length === 0) return;
56
+ const conn = connections[selectedIndex];
57
+ setMessage(`Closing connection to ${conn.metadata.host || conn.metadata.destinationIP}...`);
58
+ try {
59
+ await ClashAPI.closeConnection(conn.id);
60
+ setMessage('Connection closed.');
61
+ // Don't wait for refresh interval
62
+ setTimeout(refresh, 100);
63
+ } catch (e: any) {
64
+ setMessage(`Error: ${e.message}`);
65
+ }
66
+ }
67
+
68
+ if (input === 'X') {
69
+ setMessage('Closing ALL connections...');
70
+ try {
71
+ await ClashAPI.closeAllConnections();
72
+ setMessage('All connections closed.');
73
+ setTimeout(refresh, 100);
74
+ } catch (e: any) {
75
+ setMessage(`Error: ${e.message}`);
76
+ }
77
+ }
78
+ });
79
+
80
+ const visibleRows = 15;
81
+ let startRow = 0;
82
+ if (selectedIndex > visibleRows / 2) {
83
+ startRow = Math.min(selectedIndex - Math.floor(visibleRows / 2), connections.length - visibleRows);
84
+ }
85
+ startRow = Math.max(0, startRow);
86
+ const endRow = Math.min(startRow + visibleRows, connections.length);
87
+ const visibleConnections = connections.slice(startRow, endRow);
88
+
89
+ return (
90
+ <Box flexDirection="column" padding={1}>
91
+ <Box borderStyle="round" borderColor="blue" flexDirection="row" justifyContent="space-between" paddingX={1} marginBottom={1}>
92
+ <Text color="blue" bold>Active Connections: {connections.length}</Text>
93
+ <Box>
94
+ <Text>Total DL: <Text color="green">{formatSize(info.downloadTotal || 0)}</Text> | </Text>
95
+ <Text>Total UL: <Text color="yellow">{formatSize(info.uploadTotal || 0)}</Text></Text>
96
+ </Box>
97
+ </Box>
98
+
99
+ <Box flexDirection="column" borderStyle="single" borderColor="gray" paddingX={1} height={visibleRows + 2}>
100
+ <Box flexDirection="row" marginBottom={1}>
101
+ <Box width={30}><Text underline>Host/IP</Text></Box>
102
+ <Box width={20}><Text underline>Chains</Text></Box>
103
+ <Box width={15}><Text underline>Rule</Text></Box>
104
+ <Box width={15}><Text underline>Type</Text></Box>
105
+ </Box>
106
+
107
+ {visibleConnections.map((conn, idx) => {
108
+ const realIndex = startRow + idx;
109
+ const isSelected = realIndex === selectedIndex;
110
+ const host = conn.metadata.host || conn.metadata.destinationIP;
111
+ const chains = conn.chains.slice().reverse().join(' :: '); // Show last proxy first? No, usually first is entry.
112
+ // Actually chains usually: [ProxyGroup, Node].
113
+ const chainStr = conn.chains.length > 0 ? conn.chains[0] : 'DIRECT';
114
+
115
+ return (
116
+ <Box key={conn.id} flexDirection="row">
117
+ <Text color={isSelected ? 'cyan' : 'white'}>{isSelected ? '› ' : ' '}</Text>
118
+ <Box width={28}>
119
+ <Text color={isSelected ? 'cyan' : 'white'} wrap="truncate">
120
+ {host}:{conn.metadata.destinationPort}
121
+ </Text>
122
+ </Box>
123
+ <Box width={20}>
124
+ <Text color="gray" wrap="truncate">{chainStr}</Text>
125
+ </Box>
126
+ <Box width={15}>
127
+ <Text color="gray" wrap="truncate">{conn.rule}</Text>
128
+ </Box>
129
+ <Box width={15}>
130
+ <Text color="gray">{conn.metadata.network} ({conn.metadata.type})</Text>
131
+ </Box>
132
+ </Box>
133
+ );
134
+ })}
135
+ </Box>
136
+
137
+ <Box borderStyle="round" borderColor="gray" paddingX={1} marginTop={1}>
138
+ <Text>{message || 'Arrows: Navigate | [x]: Close Selected | [X]: Close ALL | q: Quit'}</Text>
139
+ </Box>
140
+ </Box>
141
+ );
142
+ };
143
+
144
+ export const show = async () => {
145
+ const { waitUntilExit } = render(<ConnectionsApp />);
146
+ await waitUntilExit();
147
+ };
@@ -1,11 +1,13 @@
1
1
  import React, { useState, useEffect } from 'react';
2
- import { render, Text, Box, useApp, useInput } from 'ink';
2
+ import { render, Text, Box, useApp, useInput, useStdout } from 'ink';
3
3
  import { CoreManager } from '../utils/core';
4
4
  import { ClashAPI } from '../utils/api';
5
5
  import { SubscriptionManager } from '../utils/subscription';
6
- import { LOG_FILE } from '../utils/paths';
6
+ import { LOG_FILE, SMART_LOG_FILE, SMART_PID_FILE } from '../utils/paths';
7
7
  import { spawn } from 'child_process';
8
8
  import fs from 'fs-extra';
9
+ import * as system from './system';
10
+ import { TrafficGraph } from '../components/TrafficGraph';
9
11
 
10
12
  const formatSpeed = (bytes: number) => {
11
13
  if (bytes === 0) return '0 B/s';
@@ -23,9 +25,80 @@ const formatSize = (bytes: number) => {
23
25
  return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
24
26
  };
25
27
 
26
- export const start = async () => {
28
+ const ProgressBar = ({ percent = 0, width = 20, color = 'green' }: { percent?: number, width?: number, color?: string }) => {
29
+ const safePercent = Math.min(1, Math.max(0, percent));
30
+ const completed = Math.floor(safePercent * width);
31
+ const remaining = width - completed;
32
+ return (
33
+ <Text>
34
+ <Text color={color}>{'█'.repeat(completed)}</Text>
35
+ <Text color="gray">{'░'.repeat(remaining)}</Text>
36
+ </Text>
37
+ );
38
+ };
39
+
40
+ const getSpeedPercent = (bytes: number) => {
41
+ if (bytes <= 0) return 0;
42
+ const MAX_SPEED = 10 * 1024 * 1024; // 10MB/s
43
+ // Linear scale
44
+ const p = bytes / MAX_SPEED;
45
+ return Math.min(1, p);
46
+ };
47
+
48
+ const startSmartPilot = async () => {
49
+ // Check if already running
50
+ if (await fs.pathExists(SMART_PID_FILE)) {
51
+ try {
52
+ const pid = parseInt(await fs.readFile(SMART_PID_FILE, 'utf-8'), 10);
53
+ // Check if process exists
54
+ process.kill(pid, 0);
55
+ console.log('Smart Pilot is already running.');
56
+ return;
57
+ } catch (e) {
58
+ // Process doesn't exist, remove stale PID file
59
+ await fs.remove(SMART_PID_FILE);
60
+ }
61
+ }
62
+
63
+ await fs.ensureFile(SMART_LOG_FILE);
64
+ const logFd = await fs.open(SMART_LOG_FILE, 'a');
65
+
66
+ const child = spawn(process.argv[0] || 'bun', [process.argv[1] || '', 'smart'], {
67
+ detached: true,
68
+ stdio: ['ignore', logFd, logFd]
69
+ }) as any;
70
+
71
+ if (child.pid) {
72
+ await fs.writeFile(SMART_PID_FILE, child.pid.toString());
73
+ child.unref();
74
+ console.log(`Smart Pilot started with PID ${child.pid}`);
75
+ } else {
76
+ console.error('Failed to start Smart Pilot');
77
+ }
78
+ };
79
+
80
+ const stopSmartPilot = async () => {
81
+ if (await fs.pathExists(SMART_PID_FILE)) {
82
+ const pid = parseInt(await fs.readFile(SMART_PID_FILE, 'utf-8'), 10);
83
+ if (!isNaN(pid)) {
84
+ try {
85
+ process.kill(pid);
86
+ console.log('Smart Pilot stopped.');
87
+ } catch (e) {
88
+ // Process might be already gone
89
+ }
90
+ }
91
+ await fs.remove(SMART_PID_FILE);
92
+ }
93
+ };
94
+
95
+ export const start = async (options: { smart?: boolean } = {}) => {
27
96
  try {
28
97
  await CoreManager.start();
98
+ if (options.smart) {
99
+ await startSmartPilot();
100
+ }
101
+ await system.on();
29
102
  } catch (error: any) {
30
103
  console.error('Error starting core:', error.message);
31
104
  }
@@ -33,6 +106,12 @@ export const start = async () => {
33
106
 
34
107
  export const stop = async () => {
35
108
  try {
109
+ const isProxyEnabled = await system.getSystemProxyStatus();
110
+ if (isProxyEnabled) {
111
+ console.log('System proxy is enabled. Disabling it...');
112
+ await system.off();
113
+ }
114
+ await stopSmartPilot();
36
115
  await CoreManager.stop();
37
116
  } catch (error: any) {
38
117
  console.error('Error stopping core:', error.message);
@@ -42,8 +121,11 @@ export const stop = async () => {
42
121
  export const status = async () => {
43
122
  const StatusApp = () => {
44
123
  const { exit } = useApp();
124
+ const { stdout } = useStdout();
125
+ const [width, setWidth] = useState(stdout.columns);
45
126
  const [coreStatus, setCoreStatus] = useState<any>(null);
46
127
  const [traffic, setTraffic] = useState({ up: 0, down: 0 });
128
+ const [history, setHistory] = useState<{ up: number[], down: number[] }>({ up: [], down: [] });
47
129
  const [subInfo, setSubInfo] = useState<{ active: string | null, count: number } | null>(null);
48
130
  const [proxyCount, setProxyCount] = useState<number>(0);
49
131
 
@@ -53,6 +135,18 @@ export const status = async () => {
53
135
  }
54
136
  });
55
137
 
138
+ useEffect(() => {
139
+ const onResize = () => setWidth(stdout.columns);
140
+ stdout.on('resize', onResize);
141
+ return () => {
142
+ stdout.off('resize', onResize);
143
+ };
144
+ }, [stdout]);
145
+
146
+ // Calculate dynamic graph width (terminal width - padding)
147
+ // Padding: marginLeft(2) + padding(1) + border(2) approx 6-8 chars
148
+ const graphWidth = Math.max(20, Math.min(width - 8, 120));
149
+
56
150
  useEffect(() => {
57
151
  let ws: WebSocket | null = null;
58
152
 
@@ -82,6 +176,12 @@ export const status = async () => {
82
176
  ws.onmessage = (event) => {
83
177
  const data = JSON.parse(event.data as string);
84
178
  setTraffic(data);
179
+ setHistory(prev => {
180
+ // Keep enough history for wide screens
181
+ const newUp = [...prev.up, data.up].slice(-120);
182
+ const newDown = [...prev.down, data.down].slice(-120);
183
+ return { up: newUp, down: newDown };
184
+ });
85
185
  };
86
186
  } catch (e) { }
87
187
  }
@@ -105,57 +205,71 @@ export const status = async () => {
105
205
 
106
206
  if (!coreStatus.running) {
107
207
  return (
108
- <Box borderStyle="round" borderColor="red" padding={1}>
109
- <Text color="red">Otter Core is stopped.</Text>
208
+ <Box padding={1}>
209
+ <Text color="red">● Otter Core is stopped.</Text>
110
210
  </Box>
111
211
  );
112
212
  }
113
213
 
114
214
  return (
115
- <Box flexDirection="column" borderStyle="round" borderColor="green" padding={1} width={50}>
116
- <Box justifyContent="space-between">
117
- <Text color="green" bold>Otter Core is running</Text>
118
- <Text color="gray">Press 'q' to exit</Text>
215
+ <Box flexDirection="column" padding={1}>
216
+ <Box marginBottom={1}>
217
+ <Text color="cyan" bold>● Otter Status</Text>
218
+ <Text color="gray"> (Press 'q' to exit)</Text>
119
219
  </Box>
120
220
 
121
- <Box marginTop={1} flexDirection="column">
122
- <Box>
123
- <Box width={12}><Text>PID:</Text></Box>
124
- <Text color="blue">{coreStatus.pid}</Text>
221
+ {/* System Info */}
222
+ <Box flexDirection="column" marginBottom={1}>
223
+ <Text color="blue" bold>System</Text>
224
+ <Box marginLeft={2}>
225
+ <Box width={12}><Text>PID</Text></Box>
226
+ <Text color="gray">{coreStatus.pid}</Text>
125
227
  </Box>
126
- <Box>
127
- <Box width={12}><Text>Version:</Text></Box>
128
- <Text color="yellow">{coreStatus.version}</Text>
228
+ <Box marginLeft={2}>
229
+ <Box width={12}><Text>Version</Text></Box>
230
+ <Text color="gray">{coreStatus.version}</Text>
129
231
  </Box>
130
- <Box>
131
- <Box width={12}><Text>Memory:</Text></Box>
132
- <Text color="cyan">{formatSize(coreStatus.memory || 0)}</Text>
232
+ <Box marginLeft={2}>
233
+ <Box width={12}><Text>Memory</Text></Box>
234
+ <Text color="gray">{formatSize(coreStatus.memory || 0)}</Text>
133
235
  </Box>
134
236
  </Box>
135
237
 
136
- <Box marginTop={1} borderStyle="single" borderColor="gray" flexDirection="column">
137
- <Box>
138
- <Box width={12}><Text>Active Sub:</Text></Box>
139
- <Text color="magenta">{subInfo?.active || 'None'}</Text>
140
- </Box>
141
- <Box>
142
- <Box width={12}><Text>Total Subs:</Text></Box>
143
- <Text>{subInfo?.count || 0}</Text>
238
+ {/* Configuration */}
239
+ <Box flexDirection="column" marginBottom={1}>
240
+ <Text color="magenta" bold>Configuration</Text>
241
+ <Box marginLeft={2}>
242
+ <Box width={12}><Text>Profile</Text></Box>
243
+ <Text color="gray">{subInfo?.active || 'Default'}</Text>
144
244
  </Box>
145
- <Box>
146
- <Box width={12}><Text>Proxies:</Text></Box>
147
- <Text>{proxyCount}</Text>
245
+ <Box marginLeft={2}>
246
+ <Box width={12}><Text>Proxies</Text></Box>
247
+ <Text color="gray">{proxyCount} nodes</Text>
148
248
  </Box>
149
249
  </Box>
150
250
 
151
- <Box marginTop={1} flexDirection="row" justifyContent="space-around">
152
- <Box flexDirection="column" alignItems="center">
153
- <Text>Upload</Text>
154
- <Text color="green">↑ {formatSpeed(traffic.up)}</Text>
155
- </Box>
156
- <Box flexDirection="column" alignItems="center">
157
- <Text>Download</Text>
158
- <Text color="green">↓ {formatSpeed(traffic.down)}</Text>
251
+ {/* Network Traffic */}
252
+ <Box flexDirection="column">
253
+ <Text color="green" bold>Network Traffic</Text>
254
+
255
+ <Box marginLeft={2} marginTop={1} flexDirection="column">
256
+ {/* Upload Section */}
257
+ <Box flexDirection="column" marginBottom={1}>
258
+ <Box flexDirection="row" width={graphWidth} justifyContent="space-between" marginBottom={0}>
259
+ <Text>Upload</Text>
260
+ <Text color="yellow">{formatSpeed(traffic.up)}</Text>
261
+ </Box>
262
+ <TrafficGraph data={history.up} width={graphWidth} height={4} color="yellow" />
263
+ </Box>
264
+
265
+ {/* Download Section */}
266
+ <Box flexDirection="column">
267
+ <Box flexDirection="row" width={graphWidth} justifyContent="space-between" marginBottom={0}>
268
+ <Text>Download</Text>
269
+ <Text color="green">{formatSpeed(traffic.down)}</Text>
270
+ </Box>
271
+ <TrafficGraph data={history.down} width={graphWidth} height={4} color="green" />
272
+ </Box>
159
273
  </Box>
160
274
  </Box>
161
275
  </Box>
@@ -0,0 +1,68 @@
1
+ import React from 'react';
2
+ import { render, Box, Text } from 'ink';
3
+ import SelectInput from 'ink-select-input';
4
+
5
+ const LOGO = `
6
+ ____ __ __
7
+ / __ \\/ /_/ /____ _____
8
+ / / / / __/ __/ _ \\/ ___/
9
+ / /_/ / /_/ /_/ __/ /
10
+ \\____/\\__/\\__/\\___/_/
11
+ `;
12
+
13
+ interface MenuProps {
14
+ onSelect: (value: string) => void;
15
+ }
16
+
17
+ const Menu: React.FC<MenuProps> = ({ onSelect }) => {
18
+ const items = [
19
+ { label: 'Start'.padEnd(15) + 'Start Clash core & System Proxy', value: 'start' },
20
+ { label: 'Stop'.padEnd(15) + 'Stop Clash core & Disable Proxy', value: 'stop' },
21
+ { label: 'Status'.padEnd(15) + 'Check status & traffic', value: 'status' },
22
+ { label: 'Dashboard'.padEnd(15) + 'Launch full TUI', value: 'ui' },
23
+ { label: 'Quit'.padEnd(15) + 'Exit', value: 'quit' },
24
+ ];
25
+
26
+ return (
27
+ <Box flexDirection="column" padding={1}>
28
+ <Text color="green">{LOGO}</Text>
29
+ <Text color="gray" italic> v0.0.1</Text>
30
+
31
+ <Box marginTop={1} marginBottom={1}>
32
+ <Text>
33
+ <Text color="cyan">➤</Text> Use <Text bold>Arrow Keys</Text> to select and <Text bold>Enter</Text> to confirm
34
+ </Text>
35
+ </Box>
36
+
37
+ <SelectInput
38
+ items={items}
39
+ onSelect={(item) => onSelect(item.value)}
40
+ indicatorComponent={({ isSelected }) => (
41
+ <Text color={isSelected ? 'cyan' : 'gray'}>
42
+ {isSelected ? '➤ ' : ' '}
43
+ </Text>
44
+ )}
45
+ itemComponent={({ isSelected, label }) => (
46
+ <Text color={isSelected ? 'cyan' : 'white'}>
47
+ {label}
48
+ </Text>
49
+ )}
50
+ />
51
+
52
+ <Box marginTop={1} borderStyle="single" borderColor="gray" paddingX={1}>
53
+ <Text color="gray">
54
+ <Text>↑↓</Text> Navigate | <Text>Enter</Text> Select | <Text>Q</Text> Quit
55
+ </Text>
56
+ </Box>
57
+ </Box>
58
+ );
59
+ };
60
+
61
+ export const show = () => {
62
+ return new Promise<string>((resolve) => {
63
+ const { unmount } = render(<Menu onSelect={(value) => {
64
+ unmount();
65
+ resolve(value);
66
+ }} />);
67
+ });
68
+ };
@@ -147,7 +147,7 @@ export const test = async () => {
147
147
  };
148
148
 
149
149
  export const best = async () => {
150
- console.log(chalk.blue('Testing all nodes to find the best one...'));
150
+ console.log(chalk.blue('Testing Proxy group to find the best one...'));
151
151
 
152
152
  const data = await ClashAPI.getProxies();
153
153
  if (!data || !data.proxies) {
@@ -156,17 +156,11 @@ export const best = async () => {
156
156
  }
157
157
 
158
158
  const proxies = data.proxies;
159
- const groups = Object.values(proxies).filter((p: any) => p.type === 'Selector');
160
-
161
- if (groups.length === 0) {
162
- console.error(chalk.red('No selector groups found in config.'));
163
- return;
164
- }
165
-
166
- const mainGroup: any = groups.find((g: any) => ['Proxy', 'GLOBAL'].includes(g.name)) || groups[0];
159
+ // Explicitly look for 'Proxy' group
160
+ const mainGroup: any = proxies['Proxy'];
167
161
 
168
162
  if (!mainGroup) {
169
- console.error(chalk.red('Could not determine main proxy group.'));
163
+ console.error(chalk.red("Group 'Proxy' not found."));
170
164
  return;
171
165
  }
172
166
 
@@ -0,0 +1,132 @@
1
+ import { ClashAPI } from '../utils/api';
2
+ import chalk from 'chalk';
3
+
4
+ interface Rule {
5
+ type: string;
6
+ payload: string;
7
+ proxy: string;
8
+ size?: number;
9
+ }
10
+
11
+ export const match = async (urlOrHost: string) => {
12
+ if (!urlOrHost) {
13
+ console.error(chalk.red('Please provide a URL or hostname.'));
14
+ return;
15
+ }
16
+
17
+ // Clean input to get hostname
18
+ let hostname = urlOrHost;
19
+ try {
20
+ if (!hostname.startsWith('http')) {
21
+ hostname = 'http://' + hostname;
22
+ }
23
+ hostname = new URL(hostname).hostname;
24
+ } catch (e) {
25
+ console.error(chalk.red('Invalid URL or hostname.'));
26
+ return;
27
+ }
28
+
29
+ console.log(chalk.blue(`Analyzing rules for: ${chalk.bold(hostname)}`));
30
+
31
+ const config = await ClashAPI.getConfigs();
32
+ if (config && config.mode === 'Global') {
33
+ console.log(chalk.yellow('Warning: System is in Global mode. All traffic goes to GLOBAL/Proxy.'));
34
+ }
35
+
36
+ const baseUrl = await (ClashAPI as any).getBaseUrl();
37
+ const headers = await (ClashAPI as any).getHeaders();
38
+
39
+ let rules: Rule[] = [];
40
+ try {
41
+ const res = await fetch(`${baseUrl}/rules`, { headers });
42
+ if (!res.ok) throw new Error(res.statusText);
43
+ const data = await res.json() as { rules: Rule[] };
44
+ rules = data?.rules || [];
45
+ } catch (e: any) {
46
+ console.error(chalk.red(`Failed to fetch rules: ${e.message}`));
47
+ return;
48
+ }
49
+
50
+ console.log(chalk.gray(`Loaded ${rules.length} rules.`));
51
+
52
+ let matchedRule: Rule | null = null;
53
+
54
+ for (const rule of rules) {
55
+ const type = rule.type.toLowerCase();
56
+ const payload = rule.payload;
57
+ const lowerHost = hostname.toLowerCase();
58
+ const lowerPayload = payload.toLowerCase();
59
+
60
+ let isMatch = false;
61
+
62
+ switch (type) {
63
+ case 'domain':
64
+ isMatch = lowerHost === lowerPayload;
65
+ break;
66
+ case 'domainsuffix':
67
+ case 'domain-suffix':
68
+ isMatch = lowerHost === lowerPayload || lowerHost.endsWith('.' + lowerPayload);
69
+ break;
70
+ case 'domainkeyword':
71
+ case 'domain-keyword':
72
+ isMatch = lowerHost.includes(lowerPayload);
73
+ break;
74
+ case 'match':
75
+ isMatch = true;
76
+ break;
77
+ case 'geoip':
78
+ case 'ip-cidr':
79
+ case 'ip-cidr6':
80
+ // Cannot match IP rules without DNS resolution
81
+ // We skip them but maybe log a warning if verbose
82
+ break;
83
+ default:
84
+ break;
85
+ }
86
+
87
+ if (isMatch) {
88
+ matchedRule = rule;
89
+ break; // Clash uses first match
90
+ }
91
+ }
92
+
93
+ if (matchedRule) {
94
+ console.log('');
95
+ console.log(chalk.green('✔ Match Found!'));
96
+ console.log('─'.repeat(30));
97
+ console.log(`Type: ${chalk.cyan(matchedRule.type)}`);
98
+ console.log(`Payload: ${chalk.yellow(matchedRule.payload || '(Final)')}`);
99
+ console.log(`Proxy: ${chalk.magenta(matchedRule.proxy)}`);
100
+
101
+ // Resolve Proxy Details
102
+ try {
103
+ const proxyData = await ClashAPI.getProxies();
104
+ if (proxyData && proxyData.proxies) {
105
+ const proxyName = matchedRule.proxy;
106
+ const proxyInfo = proxyData.proxies[proxyName];
107
+
108
+ if (proxyInfo) {
109
+ console.log(` └─ Type: ${proxyInfo.type}`);
110
+ if (proxyInfo.now) {
111
+ console.log(` └─ Now: ${chalk.green(proxyInfo.now)}`);
112
+ }
113
+ // If the 'now' node is also a group, we could recurse, but one level is usually enough context
114
+ if (proxyInfo.now && proxyData.proxies[proxyInfo.now]) {
115
+ const nextHop = proxyData.proxies[proxyInfo.now];
116
+ if (nextHop.type === 'Selector' || nextHop.type === 'URLTest') {
117
+ console.log(` └─ Next: ${chalk.green(nextHop.now)}`);
118
+ }
119
+ }
120
+ }
121
+ }
122
+ } catch (e) {
123
+ // Ignore proxy resolution errors
124
+ }
125
+
126
+ console.log('─'.repeat(30));
127
+ } else {
128
+ console.log('');
129
+ console.log(chalk.red('✘ No matching rule found (and no Match rule present?).'));
130
+ console.log('Traffic might fall through to default behavior.');
131
+ }
132
+ };
@@ -0,0 +1,151 @@
1
+ import { ClashAPI } from '../utils/api';
2
+ import { SubscriptionManager } from '../utils/subscription';
3
+ import * as system from './system';
4
+ import chalk from 'chalk';
5
+
6
+ const sleep = (ms: number) => new Promise(r => setTimeout(r, ms));
7
+
8
+ const findBestNode = async (nodes: string[]): Promise<{ name: string, delay: number } | null> => {
9
+ if (nodes.length === 0) return null;
10
+
11
+ // Test up to 5 nodes concurrently to save time
12
+ const candidates = nodes.slice(0, 5);
13
+
14
+ const results = await Promise.all(candidates.map(async (node) => {
15
+ try {
16
+ const res = await ClashAPI.getDelay(node) as { delay: number };
17
+ return { name: node, delay: res.delay };
18
+ } catch {
19
+ return { name: node, delay: Infinity };
20
+ }
21
+ }));
22
+
23
+ // Filter valid results and sort
24
+ const valid = results.filter(r => r.delay < 2000).sort((a, b) => a.delay - b.delay);
25
+
26
+ if (valid.length > 0) {
27
+ const best = valid[0];
28
+ if (best) {
29
+ return { name: best.name, delay: best.delay };
30
+ }
31
+ }
32
+ return null;
33
+ };
34
+
35
+ export const start = async () => {
36
+ console.log(chalk.cyan(chalk.bold('🦦 Otter Smart Pilot Initiated')));
37
+ console.log(chalk.gray('Monitoring network health and managing failover...'));
38
+ console.log(chalk.gray('Press Ctrl+C to stop.'));
39
+ console.log('─'.repeat(40));
40
+
41
+ while (true) {
42
+ try {
43
+ // 1. Health Check
44
+ const data = await ClashAPI.getProxies();
45
+ if (!data || !data.proxies) {
46
+ console.log(chalk.red('Otter Core not reachable. Retrying in 5s...'));
47
+ await sleep(5000);
48
+ continue;
49
+ }
50
+
51
+ const proxyGroup = data.proxies['Proxy'];
52
+ if (!proxyGroup) {
53
+ console.log(chalk.red("Group 'Proxy' not found."));
54
+ await sleep(10000);
55
+ continue;
56
+ }
57
+
58
+ const currentNode = proxyGroup.now;
59
+ process.stdout.write(`[${new Date().toLocaleTimeString()}] Checking ${currentNode}... `);
60
+
61
+ let isHealthy = false;
62
+ try {
63
+ const res = await ClashAPI.getDelay(currentNode) as { delay: number };
64
+ if (res.delay < 1500) {
65
+ console.log(chalk.green(`OK (${res.delay}ms)`));
66
+ isHealthy = true;
67
+ } else {
68
+ console.log(chalk.yellow(`High Latency (${res.delay}ms)`));
69
+ }
70
+ } catch {
71
+ console.log(chalk.red('Timeout'));
72
+ }
73
+
74
+ if (isHealthy) {
75
+ await sleep(10000); // Check every 10s if healthy
76
+ continue;
77
+ }
78
+
79
+ // 2. Fallback Logic
80
+ console.log(chalk.yellow('⚠ Connection unstable. Initiating fallback protocol...'));
81
+
82
+ const allNodes: string[] = proxyGroup.all;
83
+
84
+ // Regex patterns
85
+ const regions = [
86
+ { name: 'Hong Kong', regex: /HK|Hong Kong|香港/i },
87
+ { name: 'Japan', regex: /JP|Japan|日本/i },
88
+ { name: 'USA', regex: /US|USA|United States|美国/i },
89
+ ];
90
+
91
+ let fallbackSuccess = false;
92
+
93
+ for (const region of regions) {
94
+ console.log(chalk.blue(`Searching for healthy ${region.name} nodes...`));
95
+ const regionNodes = allNodes.filter(n => region.regex.test(n));
96
+
97
+ const best = await findBestNode(regionNodes);
98
+ if (best) {
99
+ console.log(chalk.green(`✔ Found ${region.name} node: ${best.name} (${best.delay}ms)`));
100
+ await ClashAPI.switchProxy('Proxy', best.name);
101
+ console.log(chalk.green(`Switched to ${best.name}`));
102
+ fallbackSuccess = true;
103
+ break;
104
+ } else {
105
+ console.log(chalk.gray(`✘ No healthy ${region.name} nodes found.`));
106
+ }
107
+ }
108
+
109
+ if (fallbackSuccess) {
110
+ await sleep(5000); // Wait a bit before next check
111
+ continue;
112
+ }
113
+
114
+ // 3. Disaster Recovery
115
+ console.log(chalk.red.bold('‼ All fallback regions failed. Initiating EMERGENCY UPDATE...'));
116
+
117
+ console.log(chalk.yellow('1. Disabling System Proxy...'));
118
+ await system.off(true); // silent
119
+
120
+ console.log(chalk.yellow('2. Updating Subscriptions...'));
121
+ const subData = await SubscriptionManager.getData();
122
+ for (const sub of subData.subscriptions) {
123
+ process.stdout.write(`Updating ${sub.name}... `);
124
+ try {
125
+ await SubscriptionManager.update(sub.name);
126
+ console.log(chalk.green('Done'));
127
+ } catch (e: any) {
128
+ console.log(chalk.red('Failed'));
129
+ }
130
+ }
131
+
132
+ console.log(chalk.yellow('3. Re-enabling System Proxy...'));
133
+ await system.on(true);
134
+
135
+ console.log(chalk.yellow('4. Selecting Best Node...'));
136
+ // Simple best node selection from all nodes
137
+ const bestAny = await findBestNode(allNodes);
138
+ if (bestAny) {
139
+ await ClashAPI.switchProxy('Proxy', bestAny.name);
140
+ console.log(chalk.green(`Recovered! Switched to ${bestAny.name}`));
141
+ } else {
142
+ console.log(chalk.red('Recovery failed. No reachable nodes found. Retrying in 1 minute...'));
143
+ await sleep(60000);
144
+ }
145
+
146
+ } catch (e: any) {
147
+ console.error(chalk.red(`Smart Pilot Error: ${e.message}`));
148
+ await sleep(5000);
149
+ }
150
+ }
151
+ };
@@ -134,7 +134,7 @@ export const mode = async (modeName?: string) => {
134
134
  return;
135
135
  }
136
136
 
137
- const validModes = ['global', 'rule', 'direct'];
137
+ const validModes = ['global', 'rule', 'direct', 'script'];
138
138
  const m = modeName.toLowerCase();
139
139
  if (!validModes.includes(m)) {
140
140
  console.error(chalk.red(`Invalid mode: ${modeName}. Valid modes: ${validModes.join(', ')}`));
@@ -0,0 +1,70 @@
1
+ import { ClashAPI } from '../utils/api';
2
+ import chalk from 'chalk';
3
+
4
+ export const test = async (target: string = 'Proxy') => {
5
+ console.log(chalk.blue(`Testing latency for: ${target}...`));
6
+
7
+ const data = await ClashAPI.getProxies();
8
+ if (!data || !data.proxies) {
9
+ console.error(chalk.red('Failed to fetch proxies. Is Otter running?'));
10
+ return;
11
+ }
12
+
13
+ const item = data.proxies[target];
14
+ if (!item) {
15
+ console.error(chalk.red(`Proxy or Group '${target}' not found.`));
16
+ const availableGroups = Object.values(data.proxies)
17
+ .filter((p: any) => p.type === 'Selector')
18
+ .map((p: any) => p.name)
19
+ .join(', ');
20
+ console.log(chalk.gray(`Available groups: ${availableGroups}`));
21
+ return;
22
+ }
23
+
24
+ let nodes: string[] = [];
25
+ if (item.all && Array.isArray(item.all)) {
26
+ nodes = item.all;
27
+ } else {
28
+ // It's a single node
29
+ nodes = [target];
30
+ }
31
+
32
+ console.log(chalk.bold(`Starting speed test for ${nodes.length} nodes...`));
33
+ console.log('─'.repeat(40));
34
+
35
+ const printResult = (name: string, delay: number | string) => {
36
+ let delayDisplay = '';
37
+ let nameDisplay = name;
38
+
39
+ if (typeof delay === 'number') {
40
+ const ms = delay;
41
+ let color = chalk.green;
42
+ if (ms > 400) color = chalk.yellow;
43
+ if (ms > 1000) color = chalk.red;
44
+
45
+ delayDisplay = color(`${ms}ms`.padEnd(8));
46
+ } else {
47
+ delayDisplay = chalk.gray('Timeout '.padEnd(8));
48
+ nameDisplay = chalk.gray(name);
49
+ }
50
+
51
+ console.log(`${delayDisplay} ${nameDisplay}`);
52
+ };
53
+
54
+ // Process in batches
55
+ const batchSize = 10;
56
+ for (let i = 0; i < nodes.length; i += batchSize) {
57
+ const batch = nodes.slice(i, i + batchSize);
58
+ await Promise.all(batch.map(async (node) => {
59
+ try {
60
+ const res = await ClashAPI.getDelay(node) as { delay: number };
61
+ printResult(node, res.delay);
62
+ } catch (e) {
63
+ printResult(node, 'Timeout');
64
+ }
65
+ }));
66
+ }
67
+
68
+ console.log('─'.repeat(40));
69
+ console.log(chalk.green('Test completed.'));
70
+ };
@@ -178,6 +178,49 @@ const TuiApp = () => {
178
178
  return;
179
179
  }
180
180
 
181
+ if (input === 'b') {
182
+ if (groups.length === 0) return;
183
+ const currentGroup = groups[selectedGroupIndex];
184
+ setMessage(`Testing ${currentGroup.name} for best node...`);
185
+
186
+ // Run test in background
187
+ (async () => {
188
+ try {
189
+ const candidates: string[] = currentGroup.all;
190
+ let bestNode = '';
191
+ let minDelay = Infinity;
192
+
193
+ // Test all nodes
194
+ const results = await Promise.all(candidates.map(async (node) => {
195
+ try {
196
+ const res = await ClashAPI.getDelay(node);
197
+ return { node, delay: res.delay };
198
+ } catch {
199
+ return { node, delay: Infinity };
200
+ }
201
+ }));
202
+
203
+ results.forEach(r => {
204
+ if (r.delay < minDelay && r.delay > 0) {
205
+ minDelay = r.delay;
206
+ bestNode = r.node;
207
+ }
208
+ });
209
+
210
+ if (bestNode) {
211
+ await ClashAPI.switchProxy(currentGroup.name, bestNode);
212
+ setMessage(`Auto-selected: ${bestNode} (${minDelay}ms)`);
213
+ await refreshProxies();
214
+ } else {
215
+ setMessage('No reachable nodes found.');
216
+ }
217
+ } catch (e: any) {
218
+ setMessage(`Error: ${e.message}`);
219
+ }
220
+ })();
221
+ return;
222
+ }
223
+
181
224
  if (groups.length === 0) return;
182
225
 
183
226
  const currentGroup = groups[selectedGroupIndex];
@@ -310,7 +353,7 @@ const TuiApp = () => {
310
353
 
311
354
  {/* Footer / Status Bar */}
312
355
  <Box borderStyle="round" borderColor="gray" paddingX={1}>
313
- <Text>{message || 'Arrows: Navigate | Enter: Select | Tab: Switch Panel | [s]: SysProxy | [m]: Mode | q: Quit'}</Text>
356
+ <Text>{message || 'Arrows: Navigate | Enter: Select | Tab: Switch Panel | [s]: SysProxy | [m]: Mode | [b]: Best | q: Quit'}</Text>
314
357
  </Box>
315
358
  </Box>
316
359
  );
@@ -0,0 +1,70 @@
1
+ import React from 'react';
2
+ import { Box, Text } from 'ink';
3
+
4
+ interface TrafficGraphProps {
5
+ data: number[];
6
+ width?: number;
7
+ height?: number;
8
+ color?: string;
9
+ max?: number;
10
+ }
11
+
12
+ const formatSize = (bytes: number) => {
13
+ if (bytes === 0) return '0 B';
14
+ const k = 1024;
15
+ const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
16
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
17
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + (sizes[i] || 'B');
18
+ };
19
+
20
+ export const TrafficGraph: React.FC<TrafficGraphProps> = ({
21
+ data,
22
+ width = 40,
23
+ height = 4,
24
+ color = 'green',
25
+ max
26
+ }) => {
27
+ // Pad with 0s at the start if data is less than width
28
+ const filledData = [...(new Array(Math.max(0, width - data.length)).fill(0)), ...data].slice(-width);
29
+
30
+ // Calculate max value for scaling
31
+ // Use a minimum max value (e.g. 1KB) to avoid flat lines on 0
32
+ const maxValue = max || Math.max(...filledData, 1024);
33
+
34
+ const rows = [];
35
+ const chars = [' ', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
36
+
37
+ for (let y = height - 1; y >= 0; y--) {
38
+ let rowStr = '';
39
+ for (let x = 0; x < width; x++) {
40
+ const value = filledData[x];
41
+ const ratio = value / maxValue;
42
+ const scaledValue = ratio * height;
43
+
44
+ if (scaledValue >= y + 1) {
45
+ rowStr += '█';
46
+ } else if (scaledValue > y) {
47
+ const remainder = scaledValue - y;
48
+ const charIndex = Math.floor(remainder * (chars.length - 1));
49
+ rowStr += chars[charIndex];
50
+ } else {
51
+ rowStr += ' ';
52
+ }
53
+ }
54
+ rows.push(rowStr);
55
+ }
56
+
57
+ return (
58
+ <Box flexDirection="column" borderStyle="round" borderColor="gray" paddingX={1}>
59
+ <Box flexDirection="column">
60
+ {rows.map((row, i) => (
61
+ <Text key={i} color={color}>{row}</Text>
62
+ ))}
63
+ </Box>
64
+ <Box justifyContent="space-between" marginTop={0}>
65
+ <Text color="gray" dimColor>0</Text>
66
+ <Text color="gray" dimColor>{formatSize(maxValue)}</Text>
67
+ </Box>
68
+ </Box>
69
+ );
70
+ };
package/src/utils/api.ts CHANGED
@@ -100,6 +100,38 @@ export class ClashAPI {
100
100
  if (!res.ok) throw new Error(`Failed to update config: ${res.statusText}`);
101
101
  }
102
102
 
103
+ static async getConnections(): Promise<any> {
104
+ const baseUrl = await this.getBaseUrl();
105
+ const headers = await this.getHeaders();
106
+ try {
107
+ const res = await fetch(`${baseUrl}/connections`, { headers });
108
+ if (!res.ok) throw new Error(`API Error: ${res.statusText}`);
109
+ return await res.json();
110
+ } catch (e) {
111
+ return null;
112
+ }
113
+ }
114
+
115
+ static async closeConnection(id: string) {
116
+ const baseUrl = await this.getBaseUrl();
117
+ const headers = await this.getHeaders();
118
+ const res = await fetch(`${baseUrl}/connections/${id}`, {
119
+ method: 'DELETE',
120
+ headers
121
+ });
122
+ if (!res.ok) throw new Error(`Failed to close connection: ${res.statusText}`);
123
+ }
124
+
125
+ static async closeAllConnections() {
126
+ const baseUrl = await this.getBaseUrl();
127
+ const headers = await this.getHeaders();
128
+ const res = await fetch(`${baseUrl}/connections`, {
129
+ method: 'DELETE',
130
+ headers
131
+ });
132
+ if (!res.ok) throw new Error(`Failed to close all connections: ${res.statusText}`);
133
+ }
134
+
103
135
  static async getTrafficUrl(): Promise<string> {
104
136
  const baseUrl = await this.getBaseUrl();
105
137
  return baseUrl.replace('http://', 'ws://') + '/traffic';
@@ -4,7 +4,9 @@ import os from 'os';
4
4
  export const HOME_DIR = path.join(os.homedir(), '.otter');
5
5
  export const CONFIG_FILE = path.join(HOME_DIR, 'config.yaml');
6
6
  export const PID_FILE = path.join(HOME_DIR, 'otter.pid');
7
+ export const SMART_PID_FILE = path.join(HOME_DIR, 'smart.pid');
7
8
  export const LOG_FILE = path.join(HOME_DIR, 'otter.log');
9
+ export const SMART_LOG_FILE = path.join(HOME_DIR, 'smart.log');
8
10
  export const BIN_PATH = path.resolve(import.meta.dir, '../../bin/mihomo');
9
11
  export const SUBSCRIPTIONS_FILE = path.join(HOME_DIR, 'subscriptions.json');
10
12
  export const PROFILES_DIR = path.join(HOME_DIR, 'profiles');