screen-manager-tui 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +141 -0
- package/bin/sm.js +2 -0
- package/dist/app.d.ts +10 -0
- package/dist/app.js +69 -0
- package/dist/components/AttachModeDialog.d.ts +9 -0
- package/dist/components/AttachModeDialog.js +16 -0
- package/dist/components/ConfirmDialog.d.ts +8 -0
- package/dist/components/ConfirmDialog.js +13 -0
- package/dist/components/Footer.d.ts +5 -0
- package/dist/components/Footer.js +20 -0
- package/dist/components/Header.d.ts +5 -0
- package/dist/components/Header.js +5 -0
- package/dist/components/NewSession.d.ts +7 -0
- package/dist/components/NewSession.js +97 -0
- package/dist/components/SessionList.d.ts +19 -0
- package/dist/components/SessionList.js +89 -0
- package/dist/hooks/useScreenSessions.d.ts +6 -0
- package/dist/hooks/useScreenSessions.js +26 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +54 -0
- package/dist/utils/dirs.d.ts +19 -0
- package/dist/utils/dirs.js +196 -0
- package/dist/utils/screen.d.ts +12 -0
- package/dist/utils/screen.js +42 -0
- package/package.json +49 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 m2kar
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# sm — Screen Manager
|
|
2
|
+
|
|
3
|
+
SSH 登录后的交互式 GNU Screen 会话管理器。基于 Ink (React for CLI) 构建,为手机竖屏 SSH 操作优化。
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
sm (3)
|
|
7
|
+
> 1 🌻 ● sm-0412-1249 12:49
|
|
8
|
+
2 🔥 ● llm-0412-1300 13:00
|
|
9
|
+
3 🐳 ○ work-0411-0900 09:00
|
|
10
|
+
────────────────────────────
|
|
11
|
+
4 + sm claude
|
|
12
|
+
5 + sm
|
|
13
|
+
6 + llm claude
|
|
14
|
+
7 + llm
|
|
15
|
+
1-9 go jk sel n new x kill r ref q quit
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## 安装
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npm install -g screen-manager-tui
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
或从源码安装:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
cd ~/sm
|
|
28
|
+
npm install
|
|
29
|
+
npm run build
|
|
30
|
+
sudo npm link
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## 使用
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
sm # 启动
|
|
37
|
+
SM_HOME=/home/zhiqing sm # 指定项目���目录
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
### 首页
|
|
41
|
+
|
|
42
|
+
| 按键 | 操作 |
|
|
43
|
+
|------|------|
|
|
44
|
+
| `1`-`9` | 直接连接已有会话 / 快速新建会话 |
|
|
45
|
+
| `j` `k` / `↑` `↓` | 上下选择 |
|
|
46
|
+
| `Enter` | 连接选中会话 |
|
|
47
|
+
| `n` | 新建会话(选择目录) |
|
|
48
|
+
| `x` | 终止选中会话 |
|
|
49
|
+
| `r` | 刷新列表 |
|
|
50
|
+
| `q` | 退出到 Shell |
|
|
51
|
+
|
|
52
|
+
首页分为两个区域:
|
|
53
|
+
|
|
54
|
+
- **已有会话** — 每个会话前有基于名称哈希的固定 emoji、状态标识(`●` 可连接 / `○` 已占用 / `✕` 已死亡)
|
|
55
|
+
- **快速新建** — 最近使用的 top 2 收藏目录 × 是否启动 Claude Code = 4 个选项,按数字一键创建并进入
|
|
56
|
+
|
|
57
|
+
### 新建会话
|
|
58
|
+
|
|
59
|
+
按 `n` 进入目录选择:
|
|
60
|
+
|
|
61
|
+
| 按键 | 操作 |
|
|
62
|
+
|------|------|
|
|
63
|
+
| `1`-`9` / `Enter` | 选择目录 |
|
|
64
|
+
| `j` `k` / `↑` `↓` | 上下选择 |
|
|
65
|
+
| `Tab` / `c` | 切换 `[x] claude` 启动选项 |
|
|
66
|
+
| `Esc` | 返回首页 |
|
|
67
|
+
|
|
68
|
+
- 会话名自动生成:`目录名-月日-时分`(如 `sm-0412-1249`)
|
|
69
|
+
- 收藏目录显示完整路径,路径过长时自动缩写(`/h/z/p/subfolder`)
|
|
70
|
+
- 选择 "Other..." 可输入自定义路径,该路径自动加入收藏
|
|
71
|
+
- `[x] claude` 默认开启,创建会话后自动执行 `claude` 命令
|
|
72
|
+
|
|
73
|
+
### 连接已占用会话
|
|
74
|
+
|
|
75
|
+
选择状态为 `○ Attached` 的会话时:
|
|
76
|
+
|
|
77
|
+
| 按键 | 操作 |
|
|
78
|
+
|------|------|
|
|
79
|
+
| `1` | 共享会话(多屏同显) |
|
|
80
|
+
| `2` | 强制接管(踢掉另一端) |
|
|
81
|
+
| `Esc` | 取消 |
|
|
82
|
+
|
|
83
|
+
### 核心交互循环
|
|
84
|
+
|
|
85
|
+
```
|
|
86
|
+
SSH 登录 → sm 启动 → 选择/新建会话 → 进入 screen
|
|
87
|
+
↑ ��
|
|
88
|
+
└──── Ctrl-A D 从 screen 断开 ────────┘
|
|
89
|
+
按 q → 退出到 Shell
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
从 screen 会话 detach 后自动回到 sm 界面,无需重新输入命令。
|
|
93
|
+
|
|
94
|
+
## 收藏与排序
|
|
95
|
+
|
|
96
|
+
- 新建会话和连接会话都会记录目录的使用时间
|
|
97
|
+
- 自定义路径自动保存为收藏
|
|
98
|
+
- 收藏按最近使用时间排序,首页快速新建始终展示 top 2
|
|
99
|
+
- 数据存储在 `$SM_HOME/.sm-data.json`
|
|
100
|
+
|
|
101
|
+
## SSH 自动启动
|
|
102
|
+
|
|
103
|
+
在 `~/.zshrc` 末尾添加:
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
if [[ -n "$SSH_CLIENT" ]] && [[ -z "$STY" ]] && [[ -z "$SM_SKIP" ]] && command -v sm &>/dev/null; then
|
|
107
|
+
export SM_HOME=/home/zhiqing
|
|
108
|
+
sm
|
|
109
|
+
fi
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
条件说明:
|
|
113
|
+
- `$SSH_CLIENT` — 仅 SSH 会话触发
|
|
114
|
+
- `$STY` — 已在 screen 中则跳过
|
|
115
|
+
- `$SM_SKIP=1` — 临时跳过 sm
|
|
116
|
+
|
|
117
|
+
## 技术栈
|
|
118
|
+
|
|
119
|
+
- [Ink 7](https://github.com/vadimdemedes/ink) (React for CLI) + React 19 + TypeScript
|
|
120
|
+
- GNU Screen 命令封装
|
|
121
|
+
- ESM + Node.js 24
|
|
122
|
+
|
|
123
|
+
## 项目结构
|
|
124
|
+
|
|
125
|
+
```
|
|
126
|
+
src/
|
|
127
|
+
├── index.tsx # 入口 + attach/re-render 主循环
|
|
128
|
+
├── app.tsx # App 组件,视图状态机
|
|
129
|
+
├── components/
|
|
130
|
+
│ ├── SessionList.tsx # 会话列表 + 快速新建
|
|
131
|
+
│ ├── NewSession.tsx # 目录选择 + claude 开关
|
|
132
|
+
│ ├── AttachModeDialog.tsx # 已占用会话的连接方式选择
|
|
133
|
+
│ ├── ConfirmDialog.tsx # 终止确认
|
|
134
|
+
│ ├── Header.tsx # 标题栏
|
|
135
|
+
│ └── Footer.tsx # 快捷键提示
|
|
136
|
+
├── hooks/
|
|
137
|
+
│ └── useScreenSessions.ts # screen 会话数据 hook
|
|
138
|
+
└── utils/
|
|
139
|
+
├── screen.ts # screen 命令封装
|
|
140
|
+
└── dirs.ts # 目录扫描、收藏、路径缩写、emoji
|
|
141
|
+
```
|
package/bin/sm.js
ADDED
package/dist/app.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export interface AttachRequest {
|
|
2
|
+
sessionId: string;
|
|
3
|
+
mode: 'reattach' | 'share' | 'takeover';
|
|
4
|
+
}
|
|
5
|
+
interface AppProps {
|
|
6
|
+
onAttach: (request: AttachRequest) => void;
|
|
7
|
+
onExit: () => void;
|
|
8
|
+
}
|
|
9
|
+
export declare function App({ onAttach, onExit }: AppProps): import("react/jsx-runtime").JSX.Element;
|
|
10
|
+
export {};
|
package/dist/app.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState, useCallback } from 'react';
|
|
3
|
+
import { Box } from 'ink';
|
|
4
|
+
import { Header } from './components/Header.js';
|
|
5
|
+
import { Footer } from './components/Footer.js';
|
|
6
|
+
import { SessionList } from './components/SessionList.js';
|
|
7
|
+
import { NewSession } from './components/NewSession.js';
|
|
8
|
+
import { ConfirmDialog } from './components/ConfirmDialog.js';
|
|
9
|
+
import { AttachModeDialog } from './components/AttachModeDialog.js';
|
|
10
|
+
import { useScreenSessions } from './hooks/useScreenSessions.js';
|
|
11
|
+
import { createSession, killSession, getSessionId } from './utils/screen.js';
|
|
12
|
+
import { saveFavorite, generateSessionName } from './utils/dirs.js';
|
|
13
|
+
export function App({ onAttach, onExit }) {
|
|
14
|
+
const [view, setView] = useState('list');
|
|
15
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
16
|
+
const [targetSession, setTargetSession] = useState(null);
|
|
17
|
+
const { sessions, refresh } = useScreenSessions();
|
|
18
|
+
const handleAttach = useCallback((session) => {
|
|
19
|
+
if (session.status === 'Attached') {
|
|
20
|
+
setTargetSession(session);
|
|
21
|
+
setView('attach-mode');
|
|
22
|
+
}
|
|
23
|
+
else if (session.status === 'Detached') {
|
|
24
|
+
onAttach({ sessionId: getSessionId(session), mode: 'reattach' });
|
|
25
|
+
}
|
|
26
|
+
}, [onAttach]);
|
|
27
|
+
const handleQuickCreate = useCallback((req) => {
|
|
28
|
+
const name = generateSessionName(req.dirPath);
|
|
29
|
+
createSession(name, req.dirPath, req.runClaude);
|
|
30
|
+
saveFavorite(req.dirPath); // also updates lastUsed
|
|
31
|
+
refresh();
|
|
32
|
+
// Find the newly created session and attach to it
|
|
33
|
+
const updated = sessions;
|
|
34
|
+
// The new session won't be in the current `sessions` yet, but we know its name
|
|
35
|
+
// We'll attach by name pattern — refresh first, then find it
|
|
36
|
+
// Simpler: just attach directly by the name we just created
|
|
37
|
+
onAttach({ sessionId: name, mode: 'reattach' });
|
|
38
|
+
}, [onAttach, refresh, sessions]);
|
|
39
|
+
const handleRequestKill = useCallback((session) => {
|
|
40
|
+
setTargetSession(session);
|
|
41
|
+
setView('confirm-kill');
|
|
42
|
+
}, []);
|
|
43
|
+
const handleKillConfirm = useCallback(() => {
|
|
44
|
+
if (targetSession) {
|
|
45
|
+
killSession(getSessionId(targetSession));
|
|
46
|
+
refresh();
|
|
47
|
+
setSelectedIndex((prev) => Math.max(0, Math.min(prev, sessions.length - 2)));
|
|
48
|
+
}
|
|
49
|
+
setView('list');
|
|
50
|
+
setTargetSession(null);
|
|
51
|
+
}, [targetSession, refresh, sessions.length]);
|
|
52
|
+
const handleNewSession = useCallback((name, cwd, runClaude) => {
|
|
53
|
+
createSession(name, cwd, runClaude);
|
|
54
|
+
saveFavorite(cwd);
|
|
55
|
+
refresh();
|
|
56
|
+
setView('list');
|
|
57
|
+
setSelectedIndex(0);
|
|
58
|
+
}, [refresh]);
|
|
59
|
+
const handleRefresh = useCallback(() => {
|
|
60
|
+
refresh();
|
|
61
|
+
}, [refresh]);
|
|
62
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Header, { sessionCount: sessions.length }), _jsxs(Box, { flexDirection: "column", minHeight: 3, children: [view === 'list' && (_jsx(SessionList, { sessions: sessions, selectedIndex: selectedIndex, onSelect: setSelectedIndex, onAttach: handleAttach, onQuickCreate: handleQuickCreate, onRequestNew: () => setView('new'), onRequestKill: handleRequestKill, onRefresh: handleRefresh, onQuit: onExit, active: view === 'list' })), view === 'new' && (_jsx(NewSession, { onSubmit: handleNewSession, onCancel: () => setView('list'), active: view === 'new' })), view === 'confirm-kill' && targetSession && (_jsx(ConfirmDialog, { sessionName: targetSession.name, onConfirm: handleKillConfirm, onCancel: () => {
|
|
63
|
+
setView('list');
|
|
64
|
+
setTargetSession(null);
|
|
65
|
+
}, active: view === 'confirm-kill' })), view === 'attach-mode' && targetSession && (_jsx(AttachModeDialog, { sessionName: targetSession.name, onShare: () => onAttach({ sessionId: getSessionId(targetSession), mode: 'share' }), onTakeover: () => onAttach({ sessionId: getSessionId(targetSession), mode: 'takeover' }), onCancel: () => {
|
|
66
|
+
setView('list');
|
|
67
|
+
setTargetSession(null);
|
|
68
|
+
}, active: view === 'attach-mode' }))] }), _jsx(Footer, { view: view })] }));
|
|
69
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
interface AttachModeDialogProps {
|
|
2
|
+
sessionName: string;
|
|
3
|
+
onShare: () => void;
|
|
4
|
+
onTakeover: () => void;
|
|
5
|
+
onCancel: () => void;
|
|
6
|
+
active: boolean;
|
|
7
|
+
}
|
|
8
|
+
export declare function AttachModeDialog({ sessionName, onShare, onTakeover, onCancel, active, }: AttachModeDialogProps): import("react/jsx-runtime").JSX.Element;
|
|
9
|
+
export {};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text, useInput } from 'ink';
|
|
3
|
+
export function AttachModeDialog({ sessionName, onShare, onTakeover, onCancel, active, }) {
|
|
4
|
+
useInput((input, key) => {
|
|
5
|
+
if (input === '1') {
|
|
6
|
+
onShare();
|
|
7
|
+
}
|
|
8
|
+
else if (input === '2') {
|
|
9
|
+
onTakeover();
|
|
10
|
+
}
|
|
11
|
+
else if (key.escape) {
|
|
12
|
+
onCancel();
|
|
13
|
+
}
|
|
14
|
+
}, { isActive: active });
|
|
15
|
+
return (_jsxs(Box, { flexDirection: "column", paddingX: 1, paddingY: 1, children: [_jsxs(Text, { children: ["Session ", _jsx(Text, { bold: true, color: "yellow", children: sessionName }), " is already attached."] }), _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { children: [_jsx(Text, { bold: true, color: "white", children: "1" }), " Share session (multi-display)"] }), _jsxs(Text, { children: [_jsx(Text, { bold: true, color: "white", children: "2" }), " Take over (detach other client)"] })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Esc to cancel" }) })] }));
|
|
16
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
interface ConfirmDialogProps {
|
|
2
|
+
sessionName: string;
|
|
3
|
+
onConfirm: () => void;
|
|
4
|
+
onCancel: () => void;
|
|
5
|
+
active: boolean;
|
|
6
|
+
}
|
|
7
|
+
export declare function ConfirmDialog({ sessionName, onConfirm, onCancel, active }: ConfirmDialogProps): import("react/jsx-runtime").JSX.Element;
|
|
8
|
+
export {};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text, useInput } from 'ink';
|
|
3
|
+
export function ConfirmDialog({ sessionName, onConfirm, onCancel, active }) {
|
|
4
|
+
useInput((input, key) => {
|
|
5
|
+
if (input === 'y') {
|
|
6
|
+
onConfirm();
|
|
7
|
+
}
|
|
8
|
+
else if (input === 'n' || key.escape) {
|
|
9
|
+
onCancel();
|
|
10
|
+
}
|
|
11
|
+
}, { isActive: active });
|
|
12
|
+
return (_jsxs(Box, { flexDirection: "column", paddingX: 1, paddingY: 1, children: [_jsxs(Text, { children: ["Kill session ", _jsx(Text, { bold: true, color: "red", children: sessionName }), "?"] }), _jsx(Text, { dimColor: true, children: "This will terminate all processes inside it." }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "[y] yes [n] no" }) })] }));
|
|
13
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
function Hint({ k, label }) {
|
|
4
|
+
return (_jsxs(Text, { dimColor: true, children: [_jsx(Text, { bold: true, color: "white", children: k }), label] }));
|
|
5
|
+
}
|
|
6
|
+
export function Footer({ view }) {
|
|
7
|
+
if (view === 'list') {
|
|
8
|
+
return (_jsxs(Box, { paddingX: 1, gap: 1, flexWrap: "wrap", children: [_jsx(Hint, { k: "1-9", label: " go" }), _jsx(Hint, { k: "jk", label: " sel" }), _jsx(Hint, { k: "n", label: " new" }), _jsx(Hint, { k: "x", label: " kill" }), _jsx(Hint, { k: "r", label: " ref" }), _jsx(Hint, { k: "q", label: " quit" })] }));
|
|
9
|
+
}
|
|
10
|
+
if (view === 'new') {
|
|
11
|
+
return (_jsxs(Box, { paddingX: 1, gap: 1, flexWrap: "wrap", children: [_jsx(Hint, { k: "1-9", label: " pick" }), _jsx(Hint, { k: "jk", label: " sel" }), _jsx(Hint, { k: "Tab", label: " claude" }), _jsx(Hint, { k: "Esc", label: " back" })] }));
|
|
12
|
+
}
|
|
13
|
+
if (view === 'confirm-kill') {
|
|
14
|
+
return (_jsxs(Box, { paddingX: 1, gap: 1, children: [_jsx(Hint, { k: "y", label: " yes" }), _jsx(Hint, { k: "n", label: " no" })] }));
|
|
15
|
+
}
|
|
16
|
+
if (view === 'attach-mode') {
|
|
17
|
+
return (_jsxs(Box, { paddingX: 1, gap: 1, flexWrap: "wrap", children: [_jsx(Hint, { k: "1", label: " share" }), _jsx(Hint, { k: "2", label: " take" }), _jsx(Hint, { k: "Esc", label: " back" })] }));
|
|
18
|
+
}
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
@@ -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({ sessionCount }) {
|
|
4
|
+
return (_jsxs(Box, { paddingX: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "sm" }), _jsxs(Text, { dimColor: true, children: [' ', "(", sessionCount, ")"] })] }));
|
|
5
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
interface NewSessionProps {
|
|
2
|
+
onSubmit: (name: string, cwd: string, runClaude: boolean) => void;
|
|
3
|
+
onCancel: () => void;
|
|
4
|
+
active: boolean;
|
|
5
|
+
}
|
|
6
|
+
export declare function NewSession({ onSubmit, onCancel, active }: NewSessionProps): import("react/jsx-runtime").JSX.Element;
|
|
7
|
+
export {};
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
import { Box, Text, useInput } from 'ink';
|
|
4
|
+
import { TextInput } from '@inkjs/ui';
|
|
5
|
+
import { getProjectDirs, getHome, generateSessionName, saveFavorite, shortenPath, } from '../utils/dirs.js';
|
|
6
|
+
export function NewSession({ onSubmit, onCancel, active }) {
|
|
7
|
+
const [step, setStep] = useState('dir');
|
|
8
|
+
const [dirs] = useState(() => getProjectDirs());
|
|
9
|
+
const [dirIndex, setDirIndex] = useState(0);
|
|
10
|
+
const [runClaude, setRunClaude] = useState(true);
|
|
11
|
+
// Items: home + dirs + "Other..."
|
|
12
|
+
const totalItems = 1 + dirs.length + 1;
|
|
13
|
+
const termWidth = process.stdout.columns || 40;
|
|
14
|
+
// Reserve space for "> 1 * " prefix (~8 chars) + name + " (" + path + ")"
|
|
15
|
+
const prefixLen = 8;
|
|
16
|
+
useInput((input, key) => {
|
|
17
|
+
if (key.escape) {
|
|
18
|
+
if (step === 'custom-path') {
|
|
19
|
+
setStep('dir');
|
|
20
|
+
}
|
|
21
|
+
else {
|
|
22
|
+
onCancel();
|
|
23
|
+
}
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
if (step !== 'dir')
|
|
27
|
+
return;
|
|
28
|
+
if (key.upArrow || input === 'k') {
|
|
29
|
+
setDirIndex((i) => Math.max(0, i - 1));
|
|
30
|
+
}
|
|
31
|
+
else if (key.downArrow || input === 'j') {
|
|
32
|
+
setDirIndex((i) => Math.min(totalItems - 1, i + 1));
|
|
33
|
+
}
|
|
34
|
+
else if (key.tab || input === 'c') {
|
|
35
|
+
setRunClaude((v) => !v);
|
|
36
|
+
}
|
|
37
|
+
else if (input >= '1' && input <= '9') {
|
|
38
|
+
const idx = parseInt(input, 10) - 1;
|
|
39
|
+
if (idx < totalItems) {
|
|
40
|
+
selectDir(idx);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
else if (key.return) {
|
|
44
|
+
selectDir(dirIndex);
|
|
45
|
+
}
|
|
46
|
+
}, { isActive: active });
|
|
47
|
+
function selectDir(idx) {
|
|
48
|
+
let dirPath;
|
|
49
|
+
if (idx === 0) {
|
|
50
|
+
dirPath = getHome();
|
|
51
|
+
}
|
|
52
|
+
else if (idx <= dirs.length) {
|
|
53
|
+
dirPath = dirs[idx - 1].path;
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
setStep('custom-path');
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const name = generateSessionName(dirPath);
|
|
60
|
+
onSubmit(name, dirPath, runClaude);
|
|
61
|
+
}
|
|
62
|
+
function handleCustomPathSubmit(value) {
|
|
63
|
+
const trimmed = value.trim();
|
|
64
|
+
if (!trimmed)
|
|
65
|
+
return;
|
|
66
|
+
saveFavorite(trimmed);
|
|
67
|
+
const name = generateSessionName(trimmed);
|
|
68
|
+
onSubmit(name, trimmed, runClaude);
|
|
69
|
+
}
|
|
70
|
+
function formatDirLabel(dir) {
|
|
71
|
+
const name = dir.name;
|
|
72
|
+
if (!dir.favorite) {
|
|
73
|
+
return `~/${name}`;
|
|
74
|
+
}
|
|
75
|
+
// Favorite: show "name (path)"
|
|
76
|
+
// Calculate available space for path
|
|
77
|
+
const baseLen = prefixLen + name.length + 3; // " (" + ")"
|
|
78
|
+
const maxPathLen = termWidth - baseLen;
|
|
79
|
+
if (maxPathLen < 8) {
|
|
80
|
+
// Too narrow, just show name
|
|
81
|
+
return name;
|
|
82
|
+
}
|
|
83
|
+
const pathDisplay = shortenPath(dir.path, maxPathLen);
|
|
84
|
+
return `${name} (${pathDisplay})`;
|
|
85
|
+
}
|
|
86
|
+
if (step === 'custom-path') {
|
|
87
|
+
return (_jsxs(Box, { flexDirection: "column", paddingX: 1, paddingY: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "New Session" }), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { children: "Path: " }), active && (_jsx(TextInput, { placeholder: "/path/to/project", onSubmit: handleCustomPathSubmit }))] }), _jsx(Text, { dimColor: true, children: "Saved to favorites." })] }));
|
|
88
|
+
}
|
|
89
|
+
return (_jsxs(Box, { flexDirection: "column", paddingX: 1, paddingY: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "New Session" }), _jsxs(Box, { flexDirection: "column", children: [_jsx(DirRow, { index: 0, selected: dirIndex === 0, label: "~" }), dirs.map((dir, i) => {
|
|
90
|
+
const idx = i + 1;
|
|
91
|
+
return (_jsx(DirRow, { index: idx, selected: dirIndex === idx, label: formatDirLabel(dir), favorite: dir.favorite }, dir.path));
|
|
92
|
+
}), _jsxs(Box, { gap: 1, children: [_jsx(Text, { color: "gray", children: dirIndex === totalItems - 1 ? '>' : ' ' }), _jsx(Text, { dimColor: true, children: " " }), _jsx(Text, { bold: dirIndex === totalItems - 1, dimColor: dirIndex !== totalItems - 1, children: "Other..." })] })] }), _jsxs(Box, { marginTop: 1, gap: 1, children: [_jsx(Text, { color: runClaude ? 'green' : 'gray', children: runClaude ? '[x]' : '[ ]' }), _jsx(Text, { bold: runClaude, dimColor: !runClaude, children: "claude" }), _jsx(Text, { dimColor: true, children: "(Tab)" })] })] }));
|
|
93
|
+
}
|
|
94
|
+
function DirRow({ index, selected, label, favorite, }) {
|
|
95
|
+
const num = index < 9 ? `${index + 1}` : ' ';
|
|
96
|
+
return (_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: "gray", children: selected ? '>' : ' ' }), _jsx(Text, { dimColor: true, children: num }), favorite ? _jsx(Text, { color: "yellow", children: "*" }) : null, _jsx(Text, { bold: selected, children: label })] }));
|
|
97
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { ScreenSession } from '../utils/screen.js';
|
|
2
|
+
export interface QuickCreateRequest {
|
|
3
|
+
dirPath: string;
|
|
4
|
+
runClaude: boolean;
|
|
5
|
+
}
|
|
6
|
+
interface SessionListProps {
|
|
7
|
+
sessions: ScreenSession[];
|
|
8
|
+
selectedIndex: number;
|
|
9
|
+
onSelect: (index: number) => void;
|
|
10
|
+
onAttach: (session: ScreenSession) => void;
|
|
11
|
+
onQuickCreate: (req: QuickCreateRequest) => void;
|
|
12
|
+
onRequestNew: () => void;
|
|
13
|
+
onRequestKill: (session: ScreenSession) => void;
|
|
14
|
+
onRefresh: () => void;
|
|
15
|
+
onQuit: () => void;
|
|
16
|
+
active: boolean;
|
|
17
|
+
}
|
|
18
|
+
export declare function SessionList({ sessions, selectedIndex, onSelect, onAttach, onQuickCreate, onRequestNew, onRequestKill, onRefresh, onQuit, active, }: SessionListProps): import("react/jsx-runtime").JSX.Element;
|
|
19
|
+
export {};
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
import { Box, Text, useInput } from 'ink';
|
|
4
|
+
import { getSessionId } from '../utils/screen.js';
|
|
5
|
+
import { getTopFavorites, emojiForName } from '../utils/dirs.js';
|
|
6
|
+
const STATUS = {
|
|
7
|
+
Detached: { icon: '●', color: 'green' },
|
|
8
|
+
Attached: { icon: '○', color: 'yellow' },
|
|
9
|
+
Dead: { icon: '✕', color: 'red' },
|
|
10
|
+
};
|
|
11
|
+
function shortDate(dateStr) {
|
|
12
|
+
const parts = dateStr.split(' ');
|
|
13
|
+
if (parts.length >= 2) {
|
|
14
|
+
return parts[1].split(':').slice(0, 2).join(':');
|
|
15
|
+
}
|
|
16
|
+
return dateStr;
|
|
17
|
+
}
|
|
18
|
+
function buildQuickItems(topFavs) {
|
|
19
|
+
const items = [];
|
|
20
|
+
for (const fav of topFavs) {
|
|
21
|
+
items.push({ dirPath: fav.path, runClaude: true });
|
|
22
|
+
items.push({ dirPath: fav.path, runClaude: false });
|
|
23
|
+
}
|
|
24
|
+
return items;
|
|
25
|
+
}
|
|
26
|
+
export function SessionList({ sessions, selectedIndex, onSelect, onAttach, onQuickCreate, onRequestNew, onRequestKill, onRefresh, onQuit, active, }) {
|
|
27
|
+
const [topFavs] = useState(() => getTopFavorites(2));
|
|
28
|
+
const quickItems = buildQuickItems(topFavs);
|
|
29
|
+
const totalItems = sessions.length + quickItems.length;
|
|
30
|
+
const termWidth = process.stdout.columns || 40;
|
|
31
|
+
useInput((input, key) => {
|
|
32
|
+
if (key.upArrow || input === 'k') {
|
|
33
|
+
onSelect(Math.max(0, selectedIndex - 1));
|
|
34
|
+
}
|
|
35
|
+
else if (key.downArrow || input === 'j') {
|
|
36
|
+
onSelect(Math.min(totalItems - 1, selectedIndex + 1));
|
|
37
|
+
}
|
|
38
|
+
else if (key.return && totalItems > 0) {
|
|
39
|
+
handleSelect(selectedIndex);
|
|
40
|
+
}
|
|
41
|
+
else if (input === 'n') {
|
|
42
|
+
onRequestNew();
|
|
43
|
+
}
|
|
44
|
+
else if (input === 'x' && selectedIndex < sessions.length && sessions.length > 0) {
|
|
45
|
+
onRequestKill(sessions[selectedIndex]);
|
|
46
|
+
}
|
|
47
|
+
else if (input === 'r') {
|
|
48
|
+
onRefresh();
|
|
49
|
+
}
|
|
50
|
+
else if (input === 'q') {
|
|
51
|
+
onQuit();
|
|
52
|
+
}
|
|
53
|
+
else if (input >= '1' && input <= '9') {
|
|
54
|
+
const idx = parseInt(input, 10) - 1;
|
|
55
|
+
if (idx < totalItems) {
|
|
56
|
+
handleSelect(idx);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}, { isActive: active });
|
|
60
|
+
function handleSelect(idx) {
|
|
61
|
+
if (idx < sessions.length) {
|
|
62
|
+
onAttach(sessions[idx]);
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
const qIdx = idx - sessions.length;
|
|
66
|
+
if (qIdx < quickItems.length) {
|
|
67
|
+
onQuickCreate(quickItems[qIdx]);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const hasNothing = sessions.length === 0 && quickItems.length === 0;
|
|
72
|
+
if (hasNothing) {
|
|
73
|
+
return (_jsxs(Box, { paddingX: 1, paddingY: 1, children: [_jsx(Text, { dimColor: true, children: "No sessions. " }), _jsx(Text, { bold: true, color: "white", children: "n" }), _jsx(Text, { dimColor: true, children: " to create." })] }));
|
|
74
|
+
}
|
|
75
|
+
return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [sessions.map((session, i) => {
|
|
76
|
+
const sel = i === selectedIndex;
|
|
77
|
+
const { icon, color } = STATUS[session.status] || STATUS.Dead;
|
|
78
|
+
const num = i < 9 ? `${i + 1}` : ' ';
|
|
79
|
+
const emoji = emojiForName(session.name);
|
|
80
|
+
return (_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: "gray", children: sel ? '>' : ' ' }), _jsx(Text, { dimColor: true, children: num }), _jsx(Text, { children: emoji }), _jsx(Text, { color: color, children: icon }), _jsx(Text, { bold: sel, children: session.name }), _jsx(Text, { dimColor: true, children: shortDate(session.date) })] }, getSessionId(session)));
|
|
81
|
+
}), sessions.length > 0 && quickItems.length > 0 && (_jsx(Box, { paddingX: 0, children: _jsx(Text, { dimColor: true, children: '─'.repeat(Math.min(termWidth - 4, 36)) }) })), quickItems.map((item, qi) => {
|
|
82
|
+
const globalIdx = sessions.length + qi;
|
|
83
|
+
const sel = globalIdx === selectedIndex;
|
|
84
|
+
const num = globalIdx < 9 ? `${globalIdx + 1}` : ' ';
|
|
85
|
+
const dirName = item.dirPath.split('/').pop() || item.dirPath;
|
|
86
|
+
const claudeTag = item.runClaude ? ' claude' : '';
|
|
87
|
+
return (_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: "gray", children: sel ? '>' : ' ' }), _jsx(Text, { dimColor: true, children: num }), _jsx(Text, { color: "magenta", children: "+" }), _jsxs(Text, { bold: sel, children: [dirName, claudeTag] })] }, `qc-${item.dirPath}-${item.runClaude}`));
|
|
88
|
+
})] }));
|
|
89
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback } from 'react';
|
|
2
|
+
import { listSessions } from '../utils/screen.js';
|
|
3
|
+
export function useScreenSessions() {
|
|
4
|
+
const [sessions, setSessions] = useState(() => {
|
|
5
|
+
try {
|
|
6
|
+
return listSessions();
|
|
7
|
+
}
|
|
8
|
+
catch {
|
|
9
|
+
return [];
|
|
10
|
+
}
|
|
11
|
+
});
|
|
12
|
+
const [error, setError] = useState(null);
|
|
13
|
+
const refresh = useCallback(() => {
|
|
14
|
+
try {
|
|
15
|
+
setSessions(listSessions());
|
|
16
|
+
setError(null);
|
|
17
|
+
}
|
|
18
|
+
catch (e) {
|
|
19
|
+
setError(e instanceof Error ? e.message : 'Failed to list sessions');
|
|
20
|
+
}
|
|
21
|
+
}, []);
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
refresh();
|
|
24
|
+
}, [refresh]);
|
|
25
|
+
return { sessions, error, refresh };
|
|
26
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { render } from 'ink';
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import { App } from './app.js';
|
|
5
|
+
import { isInsideScreen, isScreenInstalled } from './utils/screen.js';
|
|
6
|
+
import { touchFavoriteBySessionName } from './utils/dirs.js';
|
|
7
|
+
// Guard: already inside screen
|
|
8
|
+
if (isInsideScreen()) {
|
|
9
|
+
console.log('Already inside a screen session. Run `screen -ls` to see sessions.');
|
|
10
|
+
process.exit(0);
|
|
11
|
+
}
|
|
12
|
+
// Guard: screen not installed
|
|
13
|
+
if (!isScreenInstalled()) {
|
|
14
|
+
console.error('GNU Screen is not installed. Install with: sudo apt install screen');
|
|
15
|
+
process.exit(1);
|
|
16
|
+
}
|
|
17
|
+
function runTui() {
|
|
18
|
+
return new Promise((resolve) => {
|
|
19
|
+
const { unmount, waitUntilExit } = render(_jsx(App, { onAttach: (request) => {
|
|
20
|
+
resolve(request);
|
|
21
|
+
unmount();
|
|
22
|
+
}, onExit: () => {
|
|
23
|
+
resolve(null);
|
|
24
|
+
unmount();
|
|
25
|
+
} }));
|
|
26
|
+
waitUntilExit().then(() => resolve(null));
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
function extractSessionName(sessionId) {
|
|
30
|
+
// "12345.my-session" -> "my-session", or just "my-session" -> "my-session"
|
|
31
|
+
const dotIdx = sessionId.indexOf('.');
|
|
32
|
+
return dotIdx >= 0 ? sessionId.slice(dotIdx + 1) : sessionId;
|
|
33
|
+
}
|
|
34
|
+
// Main loop
|
|
35
|
+
while (true) {
|
|
36
|
+
const result = await runTui();
|
|
37
|
+
if (!result)
|
|
38
|
+
break;
|
|
39
|
+
touchFavoriteBySessionName(extractSessionName(result.sessionId));
|
|
40
|
+
let args;
|
|
41
|
+
switch (result.mode) {
|
|
42
|
+
case 'share':
|
|
43
|
+
args = ['-x', result.sessionId];
|
|
44
|
+
break;
|
|
45
|
+
case 'takeover':
|
|
46
|
+
args = ['-d', '-r', result.sessionId];
|
|
47
|
+
break;
|
|
48
|
+
case 'reattach':
|
|
49
|
+
default:
|
|
50
|
+
args = ['-r', result.sessionId];
|
|
51
|
+
break;
|
|
52
|
+
}
|
|
53
|
+
spawnSync('screen', args, { stdio: 'inherit' });
|
|
54
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export interface FavoriteEntry {
|
|
2
|
+
path: string;
|
|
3
|
+
lastUsed: number;
|
|
4
|
+
}
|
|
5
|
+
export interface ProjectDir {
|
|
6
|
+
name: string;
|
|
7
|
+
path: string;
|
|
8
|
+
favorite?: boolean;
|
|
9
|
+
}
|
|
10
|
+
export declare function getHome(): string;
|
|
11
|
+
export declare function loadFavorites(): FavoriteEntry[];
|
|
12
|
+
export declare function saveFavorite(dirPath: string): void;
|
|
13
|
+
export declare function touchFavorite(dirPath: string): void;
|
|
14
|
+
export declare function getTopFavorites(n: number): FavoriteEntry[];
|
|
15
|
+
export declare function shortenPath(fullPath: string, maxLen: number): string;
|
|
16
|
+
export declare function getProjectDirs(): ProjectDir[];
|
|
17
|
+
export declare function touchFavoriteBySessionName(sessionName: string): void;
|
|
18
|
+
export declare function emojiForName(name: string): string;
|
|
19
|
+
export declare function generateSessionName(dirPath: string): string;
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { readdirSync, readFileSync, writeFileSync, statSync } from 'node:fs';
|
|
2
|
+
import { join, basename, sep } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
const EXCLUDED_DIRS = new Set([
|
|
5
|
+
'miniconda3',
|
|
6
|
+
'anaconda3',
|
|
7
|
+
'.nvm',
|
|
8
|
+
'.npm',
|
|
9
|
+
'.cache',
|
|
10
|
+
'.local',
|
|
11
|
+
'.config',
|
|
12
|
+
'.oh-my-zsh',
|
|
13
|
+
'.claude',
|
|
14
|
+
'node_modules',
|
|
15
|
+
'.vscode-server',
|
|
16
|
+
'.cursor-server',
|
|
17
|
+
]);
|
|
18
|
+
export function getHome() {
|
|
19
|
+
return process.env.SM_HOME || homedir();
|
|
20
|
+
}
|
|
21
|
+
function getDataPath() {
|
|
22
|
+
return join(getHome(), '.sm-data.json');
|
|
23
|
+
}
|
|
24
|
+
// --- Data persistence (favorites with lastUsed) ---
|
|
25
|
+
function loadData() {
|
|
26
|
+
try {
|
|
27
|
+
const raw = readFileSync(getDataPath(), 'utf-8');
|
|
28
|
+
const parsed = JSON.parse(raw);
|
|
29
|
+
// Migrate old format (string[]) to new format
|
|
30
|
+
if (Array.isArray(parsed) && parsed.length > 0) {
|
|
31
|
+
if (typeof parsed[0] === 'string') {
|
|
32
|
+
return parsed.map((p) => ({ path: p, lastUsed: 0 }));
|
|
33
|
+
}
|
|
34
|
+
return parsed;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
// Also try old file for migration
|
|
39
|
+
try {
|
|
40
|
+
const old = readFileSync(join(getHome(), '.sm-favorites.json'), 'utf-8');
|
|
41
|
+
const arr = JSON.parse(old);
|
|
42
|
+
if (Array.isArray(arr)) {
|
|
43
|
+
return arr.map((p) => ({ path: p, lastUsed: 0 }));
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
// ignore
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return [];
|
|
51
|
+
}
|
|
52
|
+
function saveData(entries) {
|
|
53
|
+
try {
|
|
54
|
+
writeFileSync(getDataPath(), JSON.stringify(entries, null, 2));
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
// ignore
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
export function loadFavorites() {
|
|
61
|
+
return loadData().sort((a, b) => b.lastUsed - a.lastUsed);
|
|
62
|
+
}
|
|
63
|
+
export function saveFavorite(dirPath) {
|
|
64
|
+
const entries = loadData();
|
|
65
|
+
const existing = entries.find((e) => e.path === dirPath);
|
|
66
|
+
if (existing) {
|
|
67
|
+
existing.lastUsed = Date.now();
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
entries.push({ path: dirPath, lastUsed: Date.now() });
|
|
71
|
+
}
|
|
72
|
+
saveData(entries);
|
|
73
|
+
}
|
|
74
|
+
export function touchFavorite(dirPath) {
|
|
75
|
+
const entries = loadData();
|
|
76
|
+
const existing = entries.find((e) => e.path === dirPath);
|
|
77
|
+
if (existing) {
|
|
78
|
+
existing.lastUsed = Date.now();
|
|
79
|
+
saveData(entries);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
export function getTopFavorites(n) {
|
|
83
|
+
return loadFavorites()
|
|
84
|
+
.filter((e) => {
|
|
85
|
+
try {
|
|
86
|
+
statSync(e.path);
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
})
|
|
93
|
+
.slice(0, n);
|
|
94
|
+
}
|
|
95
|
+
// --- Path display helpers ---
|
|
96
|
+
export function shortenPath(fullPath, maxLen) {
|
|
97
|
+
if (fullPath.length <= maxLen)
|
|
98
|
+
return fullPath;
|
|
99
|
+
const home = getHome();
|
|
100
|
+
let display = fullPath;
|
|
101
|
+
if (fullPath.startsWith(home)) {
|
|
102
|
+
display = '~' + fullPath.slice(home.length);
|
|
103
|
+
}
|
|
104
|
+
if (display.length <= maxLen)
|
|
105
|
+
return display;
|
|
106
|
+
// Abbreviate: /home/user/projects/subfolder -> /h/u/p/subfolder
|
|
107
|
+
const parts = fullPath.split(sep).filter(Boolean);
|
|
108
|
+
if (parts.length <= 1)
|
|
109
|
+
return display;
|
|
110
|
+
// Keep last part full, abbreviate earlier parts to first char
|
|
111
|
+
const last = parts[parts.length - 1];
|
|
112
|
+
const abbreviated = parts.slice(0, -1).map((p) => p[0]);
|
|
113
|
+
const result = sep + abbreviated.join(sep) + sep + last;
|
|
114
|
+
return result.length <= maxLen ? result : last;
|
|
115
|
+
}
|
|
116
|
+
// --- Project directory listing ---
|
|
117
|
+
export function getProjectDirs() {
|
|
118
|
+
const home = getHome();
|
|
119
|
+
const favorites = loadFavorites();
|
|
120
|
+
const favPaths = new Set(favorites.map((f) => f.path));
|
|
121
|
+
const dirs = [];
|
|
122
|
+
// Favorites first, sorted by lastUsed (already sorted)
|
|
123
|
+
for (const fav of favorites) {
|
|
124
|
+
try {
|
|
125
|
+
statSync(fav.path);
|
|
126
|
+
dirs.push({ name: basename(fav.path), path: fav.path, favorite: true });
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
// skip non-existent
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
// Home subdirectories (excluding favorites)
|
|
133
|
+
try {
|
|
134
|
+
const entries = readdirSync(home, { withFileTypes: true });
|
|
135
|
+
for (const entry of entries) {
|
|
136
|
+
if (!entry.isDirectory())
|
|
137
|
+
continue;
|
|
138
|
+
if (entry.name.startsWith('.'))
|
|
139
|
+
continue;
|
|
140
|
+
if (EXCLUDED_DIRS.has(entry.name))
|
|
141
|
+
continue;
|
|
142
|
+
const fullPath = join(home, entry.name);
|
|
143
|
+
if (favPaths.has(fullPath))
|
|
144
|
+
continue;
|
|
145
|
+
try {
|
|
146
|
+
statSync(fullPath);
|
|
147
|
+
dirs.push({ name: entry.name, path: fullPath });
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
// skip
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
// ignore
|
|
156
|
+
}
|
|
157
|
+
return dirs;
|
|
158
|
+
}
|
|
159
|
+
export function touchFavoriteBySessionName(sessionName) {
|
|
160
|
+
// Session names are "dirname-MMDD-HHMM"
|
|
161
|
+
const match = sessionName.match(/^(.+)-\d{4}-\d{4}$/);
|
|
162
|
+
if (!match)
|
|
163
|
+
return;
|
|
164
|
+
const dirName = match[1];
|
|
165
|
+
const entries = loadData();
|
|
166
|
+
const found = entries.find((e) => basename(e.path) === dirName);
|
|
167
|
+
if (found) {
|
|
168
|
+
found.lastUsed = Date.now();
|
|
169
|
+
saveData(entries);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
const EMOJIS = [
|
|
173
|
+
'🚀', '⚡', '🔥', '💎', '🌟', '🎯', '🧪', '🔬', '🛠️', '🎨',
|
|
174
|
+
'🌊', '🍀', '🦊', '🐙', '🦋', '🐝', '🌵', '🍄', '🎲', '🧩',
|
|
175
|
+
'🏔️', '🌈', '☕', '🎸', '🔮', '🧲', '💡', '📡', '🛸', '🤖',
|
|
176
|
+
'🐳', '🦉', '🐺', '🐢', '🦎', '🌻', '🍊', '🫐', '🥝', '🧊',
|
|
177
|
+
];
|
|
178
|
+
function hashString(str) {
|
|
179
|
+
let hash = 0;
|
|
180
|
+
for (let i = 0; i < str.length; i++) {
|
|
181
|
+
hash = ((hash << 5) - hash + str.charCodeAt(i)) | 0;
|
|
182
|
+
}
|
|
183
|
+
return Math.abs(hash);
|
|
184
|
+
}
|
|
185
|
+
export function emojiForName(name) {
|
|
186
|
+
return EMOJIS[hashString(name) % EMOJIS.length];
|
|
187
|
+
}
|
|
188
|
+
export function generateSessionName(dirPath) {
|
|
189
|
+
const dirName = basename(dirPath);
|
|
190
|
+
const now = new Date();
|
|
191
|
+
const mm = String(now.getMonth() + 1).padStart(2, '0');
|
|
192
|
+
const dd = String(now.getDate()).padStart(2, '0');
|
|
193
|
+
const hh = String(now.getHours()).padStart(2, '0');
|
|
194
|
+
const mi = String(now.getMinutes()).padStart(2, '0');
|
|
195
|
+
return `${dirName}-${mm}${dd}-${hh}${mi}`;
|
|
196
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export interface ScreenSession {
|
|
2
|
+
pid: number;
|
|
3
|
+
name: string;
|
|
4
|
+
date: string;
|
|
5
|
+
status: 'Attached' | 'Detached' | 'Dead';
|
|
6
|
+
}
|
|
7
|
+
export declare function listSessions(): ScreenSession[];
|
|
8
|
+
export declare function createSession(name: string, cwd?: string, runClaude?: boolean): void;
|
|
9
|
+
export declare function killSession(id: string): void;
|
|
10
|
+
export declare function isInsideScreen(): boolean;
|
|
11
|
+
export declare function isScreenInstalled(): boolean;
|
|
12
|
+
export declare function getSessionId(session: ScreenSession): string;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
export function listSessions() {
|
|
4
|
+
const result = spawnSync('screen', ['-ls'], { encoding: 'utf-8' });
|
|
5
|
+
const output = result.stdout || result.stderr || '';
|
|
6
|
+
const sessions = [];
|
|
7
|
+
const regex = /^\t(\d+)\.(.+?)\t\((.+?)\)\t\((\w+)\)/gm;
|
|
8
|
+
let match;
|
|
9
|
+
while ((match = regex.exec(output)) !== null) {
|
|
10
|
+
sessions.push({
|
|
11
|
+
pid: parseInt(match[1], 10),
|
|
12
|
+
name: match[2],
|
|
13
|
+
date: match[3],
|
|
14
|
+
status: match[4],
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
return sessions;
|
|
18
|
+
}
|
|
19
|
+
export function createSession(name, cwd, runClaude) {
|
|
20
|
+
const targetCwd = cwd || process.env.SM_HOME || homedir();
|
|
21
|
+
if (runClaude) {
|
|
22
|
+
// Create session and send the claude command into it
|
|
23
|
+
spawnSync('screen', ['-dmS', name], { cwd: targetCwd, stdio: 'ignore' });
|
|
24
|
+
spawnSync('screen', ['-S', name, '-X', 'stuff', 'claude\n'], { stdio: 'ignore' });
|
|
25
|
+
}
|
|
26
|
+
else {
|
|
27
|
+
spawnSync('screen', ['-dmS', name], { cwd: targetCwd, stdio: 'ignore' });
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export function killSession(id) {
|
|
31
|
+
spawnSync('screen', ['-X', '-S', id, 'quit'], { stdio: 'ignore' });
|
|
32
|
+
}
|
|
33
|
+
export function isInsideScreen() {
|
|
34
|
+
return !!process.env.STY;
|
|
35
|
+
}
|
|
36
|
+
export function isScreenInstalled() {
|
|
37
|
+
const result = spawnSync('screen', ['--version'], { encoding: 'utf-8' });
|
|
38
|
+
return result.status === 0 || (result.stderr || '').includes('Screen version');
|
|
39
|
+
}
|
|
40
|
+
export function getSessionId(session) {
|
|
41
|
+
return `${session.pid}.${session.name}`;
|
|
42
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "screen-manager-tui",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Screen Manager TUI - Interactive GNU Screen session management tool with React-based terminal UI",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"sm": "bin/sm.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin/",
|
|
11
|
+
"dist/",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"build": "tsc",
|
|
16
|
+
"dev": "tsc --watch",
|
|
17
|
+
"start": "node bin/sm.js",
|
|
18
|
+
"prepublishOnly": "tsc"
|
|
19
|
+
},
|
|
20
|
+
"keywords": [
|
|
21
|
+
"screen",
|
|
22
|
+
"gnu-screen",
|
|
23
|
+
"session-manager",
|
|
24
|
+
"tui",
|
|
25
|
+
"terminal",
|
|
26
|
+
"ink",
|
|
27
|
+
"cli",
|
|
28
|
+
"ssh"
|
|
29
|
+
],
|
|
30
|
+
"author": "m2kar <zhiqing.rui@gmail.com>",
|
|
31
|
+
"license": "MIT",
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "git+https://github.com/m2kar/sm.git"
|
|
35
|
+
},
|
|
36
|
+
"engines": {
|
|
37
|
+
"node": ">=18"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"@inkjs/ui": "^2.0.0",
|
|
41
|
+
"ink": "^7.0.0",
|
|
42
|
+
"react": "^19.2.0"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@types/node": "^25.6.0",
|
|
46
|
+
"@types/react": "^19.2.0",
|
|
47
|
+
"typescript": "^5.8.0"
|
|
48
|
+
}
|
|
49
|
+
}
|