tmuxes 0.1.7 → 0.1.9
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/.node-version +1 -0
- package/.nvmrc +1 -0
- package/.tmp-npm-cache/_cacache/content-v2/sha512/43/27/5e000b8b9c56a6ccc66f709485499f4304e2cb1982582ba571321c07b3ef56fcabd2c671898cc8003365a0485b6fd8e73e7b17b073cec0f7d1628c1a99df +0 -0
- package/.tmp-npm-cache/_cacache/content-v2/sha512/51/cf/4301295d74559ed494bae160d54d8741077f89faebb311882ac065019246951e7b53f3dcb913793c42b331e14c7070c4810c3cdc27a427d103a7db4614e0 +0 -0
- package/.tmp-npm-cache/_cacache/content-v2/sha512/c3/4d/d68a454a916e74c2617f586fbf770981b33811d667c2547eb0e9fc21938f4ee7e98f1ceee4bde8ad8815b5f6efe21b60eee798837d68f51a3340d7e5bb7a +0 -0
- package/.tmp-npm-cache/_cacache/content-v2/sha512/fe/40/2abfbefc96299e8bf714aa91d62607190ae299e102cf5933db2e2904640d65d25d67dbbb6fa2ddc92a17f00b9dbfdf2e37487f67d96ec36c64a285b59a7d +0 -0
- package/.tmp-npm-cache/_cacache/index-v5/27/fe/81a3de6ce7ae3d1e41a3421de20c5629998c4ee5d0ffe2037630f03b03b2 +4 -0
- package/.tmp-npm-cache/_cacache/index-v5/65/22/dd66711f62681fce09aabb2357a2907b4a0c778ac5227c4baf9603fd86e8 +4 -0
- package/.tmp-npm-cache/_update-notifier-last-checked +0 -0
- package/AGENTS.md +15 -0
- package/CLAUDE.md +3 -0
- package/LICENSE +21 -21
- package/README.en.md +304 -0
- package/README.md +301 -289
- package/SECURITY.md +31 -0
- package/{public → client}/index.html +12 -13
- package/client/package.json +29 -0
- package/client/src/App.tsx +123 -0
- package/client/src/activity.ts +5 -0
- package/client/src/api.ts +130 -0
- package/client/src/attention.tsx +157 -0
- package/client/src/components/FileExplorer.tsx +156 -0
- package/client/src/components/FileViewer.tsx +194 -0
- package/client/src/components/SessionRow.tsx +108 -0
- package/client/src/components/SessionTree.tsx +197 -0
- package/client/src/components/SettingsButton.tsx +122 -0
- package/client/src/components/Sidebar.tsx +96 -0
- package/client/src/components/StatusBanner.tsx +31 -0
- package/client/src/components/TargetGroup.tsx +275 -0
- package/client/src/components/TerminalPanel.tsx +192 -0
- package/client/src/folders.ts +245 -0
- package/client/src/hooks/useTerminal.ts +67 -0
- package/client/src/hooks/useTmuxSocket.ts +65 -0
- package/client/src/i18n.ts +213 -0
- package/client/src/main.tsx +17 -0
- package/client/src/settings.tsx +87 -0
- package/client/src/styles.css +723 -0
- package/client/src/types.ts +93 -0
- package/client/src/util.ts +65 -0
- package/client/tsconfig.json +13 -0
- package/client/vite.config.ts +15 -0
- package/fig/fig1.png +0 -0
- package/package.json +28 -61
- package/scripts/prepack.mjs +35 -0
- package/{bin → server/bin}/tmuxes.js +36 -36
- package/server/package.json +61 -0
- package/server/src/agentHooks.ts +120 -0
- package/server/src/agentOutput.ts +36 -0
- package/server/src/agentState.ts +70 -0
- package/server/src/config.ts +31 -0
- package/server/src/exe.ts +34 -0
- package/server/src/exec.ts +61 -0
- package/server/src/files.ts +330 -0
- package/server/src/foldersStore.ts +114 -0
- package/server/src/index.ts +114 -0
- package/server/src/logger.ts +16 -0
- package/{dist/monitor.js → server/src/monitor.ts} +10 -9
- package/server/src/openBrowser.ts +28 -0
- package/{dist/platform.js → server/src/platform.ts} +4 -5
- package/server/src/rest/router.ts +290 -0
- package/server/src/targetCommand.ts +79 -0
- package/server/src/targets.ts +152 -0
- package/server/src/tmux/builder.ts +198 -0
- package/server/src/tmux/formats.ts +95 -0
- package/server/src/tmux/sessions.ts +204 -0
- package/server/src/validate.ts +79 -0
- package/server/src/windowsSsh.ts +239 -0
- package/server/src/winshell/manager.ts +296 -0
- package/server/src/ws/protocol.ts +15 -0
- package/server/src/ws/sshState.ts +36 -0
- package/server/src/ws/terminalSession.ts +207 -0
- package/server/src/ws/wsServer.ts +153 -0
- package/server/src/wsl.ts +38 -0
- package/server/test/agentHooks.test.ts +66 -0
- package/server/test/agentOutput.test.ts +26 -0
- package/server/test/agentState.test.ts +24 -0
- package/server/test/builder.test.ts +162 -0
- package/server/test/files.test.ts +81 -0
- package/server/test/formats.test.ts +123 -0
- package/server/test/monitor.test.ts +25 -0
- package/server/test/validate.test.ts +71 -0
- package/server/test/wsl.test.ts +18 -0
- package/server/tsconfig.json +9 -0
- package/server/vitest.config.ts +12 -0
- package/start.cmd +30 -0
- package/start.command +20 -0
- package/start.sh +20 -0
- package/tsconfig.base.json +19 -0
- package/dist/agentHooks.js +0 -91
- package/dist/agentHooks.js.map +0 -1
- package/dist/agentOutput.js +0 -30
- package/dist/agentOutput.js.map +0 -1
- package/dist/agentState.js +0 -45
- package/dist/agentState.js.map +0 -1
- package/dist/config.js +0 -32
- package/dist/config.js.map +0 -1
- package/dist/exe.js +0 -37
- package/dist/exe.js.map +0 -1
- package/dist/exec.js +0 -43
- package/dist/exec.js.map +0 -1
- package/dist/files.js +0 -243
- package/dist/files.js.map +0 -1
- package/dist/foldersStore.js +0 -103
- package/dist/foldersStore.js.map +0 -1
- package/dist/index.js +0 -117
- package/dist/index.js.map +0 -1
- package/dist/logger.js +0 -16
- package/dist/logger.js.map +0 -1
- package/dist/monitor.js.map +0 -1
- package/dist/openBrowser.js +0 -31
- package/dist/openBrowser.js.map +0 -1
- package/dist/platform.js.map +0 -1
- package/dist/rest/router.js +0 -190
- package/dist/rest/router.js.map +0 -1
- package/dist/targetCommand.js +0 -41
- package/dist/targetCommand.js.map +0 -1
- package/dist/targets.js +0 -131
- package/dist/targets.js.map +0 -1
- package/dist/tmux/builder.js +0 -173
- package/dist/tmux/builder.js.map +0 -1
- package/dist/tmux/formats.js +0 -61
- package/dist/tmux/formats.js.map +0 -1
- package/dist/tmux/sessions.js +0 -157
- package/dist/tmux/sessions.js.map +0 -1
- package/dist/validate.js +0 -65
- package/dist/validate.js.map +0 -1
- package/dist/winshell/manager.js +0 -267
- package/dist/winshell/manager.js.map +0 -1
- package/dist/ws/protocol.js +0 -4
- package/dist/ws/protocol.js.map +0 -1
- package/dist/ws/sshState.js +0 -35
- package/dist/ws/sshState.js.map +0 -1
- package/dist/ws/terminalSession.js +0 -204
- package/dist/ws/terminalSession.js.map +0 -1
- package/dist/ws/wsServer.js +0 -151
- package/dist/ws/wsServer.js.map +0 -1
- package/dist/wsl.js +0 -35
- package/dist/wsl.js.map +0 -1
- package/public/assets/index-BpVrfoZw.js +0 -44
- package/public/assets/index-D_X5SnGx.css +0 -1
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { api } from './api';
|
|
3
|
+
|
|
4
|
+
/** A virtual folder used to organize tmux sessions in the sidebar tree.
|
|
5
|
+
* Stored ON THE TARGET (server: $HOME/.config/tmuxes/folders.json) so the tree
|
|
6
|
+
* follows the cluster and syncs across browsers/machines. localStorage is kept
|
|
7
|
+
* only as an offline cache + one-time migration source. */
|
|
8
|
+
export interface FolderNode {
|
|
9
|
+
id: string;
|
|
10
|
+
name: string;
|
|
11
|
+
parentId: string | null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface TargetFolders {
|
|
15
|
+
folders: FolderNode[];
|
|
16
|
+
/** sessionName -> folderId (only non-root assignments are stored). */
|
|
17
|
+
assign: Record<string, string>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const EMPTY: TargetFolders = { folders: [], assign: {} };
|
|
21
|
+
const CACHE_KEY = 'tmuxes.folders';
|
|
22
|
+
const POLL_MS = 15_000;
|
|
23
|
+
const SAVE_DEBOUNCE_MS = 350;
|
|
24
|
+
|
|
25
|
+
type Cache = Record<string, TargetFolders>;
|
|
26
|
+
|
|
27
|
+
function loadCacheStore(): Cache {
|
|
28
|
+
try {
|
|
29
|
+
return JSON.parse(localStorage.getItem(CACHE_KEY) || '{}') as Cache;
|
|
30
|
+
} catch {
|
|
31
|
+
return {};
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function loadCache(targetId: string): TargetFolders {
|
|
35
|
+
return loadCacheStore()[targetId] ?? EMPTY;
|
|
36
|
+
}
|
|
37
|
+
function saveCache(targetId: string, slice: TargetFolders): void {
|
|
38
|
+
const store = loadCacheStore();
|
|
39
|
+
store[targetId] = slice;
|
|
40
|
+
try {
|
|
41
|
+
localStorage.setItem(CACHE_KEY, JSON.stringify(store));
|
|
42
|
+
} catch {
|
|
43
|
+
/* ignore */
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function isEmpty(s: TargetFolders): boolean {
|
|
48
|
+
return s.folders.length === 0 && Object.keys(s.assign).length === 0;
|
|
49
|
+
}
|
|
50
|
+
function sameJson(a: TargetFolders, b: TargetFolders): boolean {
|
|
51
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
52
|
+
}
|
|
53
|
+
function coerce(payload: { folders: unknown[]; assign: Record<string, unknown> }): TargetFolders {
|
|
54
|
+
return { folders: payload.folders as FolderNode[], assign: payload.assign as Record<string, string> };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function newId(): string {
|
|
58
|
+
try {
|
|
59
|
+
return crypto.randomUUID();
|
|
60
|
+
} catch {
|
|
61
|
+
return `f-${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** True if `maybeAncestor` is `node` or an ancestor of `node` (cycle guard). */
|
|
66
|
+
function isAncestor(folders: FolderNode[], maybeAncestor: string, node: string | null): boolean {
|
|
67
|
+
let cur = node;
|
|
68
|
+
while (cur) {
|
|
69
|
+
if (cur === maybeAncestor) return true;
|
|
70
|
+
cur = folders.find((f) => f.id === cur)?.parentId ?? null;
|
|
71
|
+
}
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface FoldersApi {
|
|
76
|
+
folders: FolderNode[];
|
|
77
|
+
folderOf: (sessionName: string) => string | null;
|
|
78
|
+
addFolder: (parentId: string | null, name?: string) => void;
|
|
79
|
+
renameFolder: (id: string, name: string) => void;
|
|
80
|
+
deleteFolder: (id: string) => void;
|
|
81
|
+
moveSession: (sessionName: string, folderId: string | null) => void;
|
|
82
|
+
moveFolder: (id: string, parentId: string | null) => void;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function useFolders(targetId: string, enabled: boolean, pauseOnError = false): FoldersApi {
|
|
86
|
+
const [slice, setSlice] = useState<TargetFolders>(() => loadCache(targetId));
|
|
87
|
+
const sliceRef = useRef(slice);
|
|
88
|
+
const pendingSave = useRef(false);
|
|
89
|
+
const pollingPaused = useRef(false);
|
|
90
|
+
const saveTimer = useRef<number | undefined>(undefined);
|
|
91
|
+
|
|
92
|
+
const applySlice = useCallback((next: TargetFolders) => {
|
|
93
|
+
sliceRef.current = next;
|
|
94
|
+
setSlice(next);
|
|
95
|
+
}, []);
|
|
96
|
+
|
|
97
|
+
const saveToServer = useCallback(
|
|
98
|
+
(data: TargetFolders) => {
|
|
99
|
+
pendingSave.current = true;
|
|
100
|
+
return api
|
|
101
|
+
.saveFolders(targetId, { folders: data.folders, assign: data.assign })
|
|
102
|
+
.then(() => {
|
|
103
|
+
pollingPaused.current = false;
|
|
104
|
+
saveCache(targetId, data);
|
|
105
|
+
})
|
|
106
|
+
.catch(() => {
|
|
107
|
+
if (pauseOnError) pollingPaused.current = true;
|
|
108
|
+
/* offline / host down — cache keeps it; retried on next change */
|
|
109
|
+
})
|
|
110
|
+
.finally(() => {
|
|
111
|
+
pendingSave.current = false;
|
|
112
|
+
});
|
|
113
|
+
},
|
|
114
|
+
[targetId, pauseOnError],
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
// Load from the target on open (with localStorage migration + cache fallback).
|
|
118
|
+
useEffect(() => {
|
|
119
|
+
if (!enabled) return;
|
|
120
|
+
let cancelled = false;
|
|
121
|
+
pollingPaused.current = false;
|
|
122
|
+
const cached = loadCache(targetId);
|
|
123
|
+
applySlice(cached);
|
|
124
|
+
api
|
|
125
|
+
.getFolders(targetId)
|
|
126
|
+
.then((payload) => {
|
|
127
|
+
if (cancelled) return;
|
|
128
|
+
const remote = coerce(payload);
|
|
129
|
+
if (isEmpty(remote) && !isEmpty(cached)) {
|
|
130
|
+
// First run on the server side — push existing local folders up.
|
|
131
|
+
applySlice(cached);
|
|
132
|
+
void saveToServer(cached);
|
|
133
|
+
} else {
|
|
134
|
+
applySlice(remote);
|
|
135
|
+
saveCache(targetId, remote);
|
|
136
|
+
}
|
|
137
|
+
})
|
|
138
|
+
.catch(() => {
|
|
139
|
+
if (cancelled) return;
|
|
140
|
+
if (pauseOnError) pollingPaused.current = true;
|
|
141
|
+
/* keep cache */
|
|
142
|
+
});
|
|
143
|
+
return () => {
|
|
144
|
+
cancelled = true;
|
|
145
|
+
};
|
|
146
|
+
}, [targetId, enabled, applySlice, saveToServer]);
|
|
147
|
+
|
|
148
|
+
// Light poll so changes from another browser/machine show up here too.
|
|
149
|
+
useEffect(() => {
|
|
150
|
+
if (!enabled) return;
|
|
151
|
+
const id = window.setInterval(() => {
|
|
152
|
+
if (pollingPaused.current) return;
|
|
153
|
+
if (pendingSave.current) return;
|
|
154
|
+
api
|
|
155
|
+
.getFolders(targetId)
|
|
156
|
+
.then((payload) => {
|
|
157
|
+
if (pendingSave.current) return;
|
|
158
|
+
const remote = coerce(payload);
|
|
159
|
+
if (!sameJson(remote, sliceRef.current)) {
|
|
160
|
+
applySlice(remote);
|
|
161
|
+
saveCache(targetId, remote);
|
|
162
|
+
}
|
|
163
|
+
})
|
|
164
|
+
.catch(() => {
|
|
165
|
+
if (pauseOnError) pollingPaused.current = true;
|
|
166
|
+
/* transient — keep current */
|
|
167
|
+
});
|
|
168
|
+
}, POLL_MS);
|
|
169
|
+
return () => window.clearInterval(id);
|
|
170
|
+
}, [targetId, enabled, pauseOnError, applySlice]);
|
|
171
|
+
|
|
172
|
+
const update = useCallback(
|
|
173
|
+
(fn: (prev: TargetFolders) => TargetFolders) => {
|
|
174
|
+
const next = fn(sliceRef.current);
|
|
175
|
+
applySlice(next);
|
|
176
|
+
saveCache(targetId, next);
|
|
177
|
+
pendingSave.current = true; // block poll clobber until the save lands
|
|
178
|
+
window.clearTimeout(saveTimer.current);
|
|
179
|
+
saveTimer.current = window.setTimeout(() => void saveToServer(next), SAVE_DEBOUNCE_MS);
|
|
180
|
+
},
|
|
181
|
+
[targetId, applySlice, saveToServer],
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
const folderOf = useCallback(
|
|
185
|
+
(sessionName: string): string | null => {
|
|
186
|
+
const id = slice.assign[sessionName];
|
|
187
|
+
if (id && slice.folders.some((f) => f.id === id)) return id;
|
|
188
|
+
return null;
|
|
189
|
+
},
|
|
190
|
+
[slice],
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
const addFolder = useCallback(
|
|
194
|
+
(parentId: string | null, name = '新建文件夹') =>
|
|
195
|
+
update((p) => ({ ...p, folders: [...p.folders, { id: newId(), name, parentId }] })),
|
|
196
|
+
[update],
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
const renameFolder = useCallback(
|
|
200
|
+
(id: string, name: string) =>
|
|
201
|
+
update((p) => ({ ...p, folders: p.folders.map((f) => (f.id === id ? { ...f, name } : f)) })),
|
|
202
|
+
[update],
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
const deleteFolder = useCallback(
|
|
206
|
+
(id: string) =>
|
|
207
|
+
update((p) => {
|
|
208
|
+
const target = p.folders.find((f) => f.id === id);
|
|
209
|
+
const parent = target?.parentId ?? null;
|
|
210
|
+
const folders = p.folders
|
|
211
|
+
.filter((f) => f.id !== id)
|
|
212
|
+
.map((f) => (f.parentId === id ? { ...f, parentId: parent } : f));
|
|
213
|
+
const assign: Record<string, string> = {};
|
|
214
|
+
for (const [name, fid] of Object.entries(p.assign)) {
|
|
215
|
+
const moved = fid === id ? (parent ?? '') : fid;
|
|
216
|
+
if (moved) assign[name] = moved;
|
|
217
|
+
}
|
|
218
|
+
return { folders, assign };
|
|
219
|
+
}),
|
|
220
|
+
[update],
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
const moveSession = useCallback(
|
|
224
|
+
(sessionName: string, folderId: string | null) =>
|
|
225
|
+
update((p) => {
|
|
226
|
+
const assign = { ...p.assign };
|
|
227
|
+
if (folderId) assign[sessionName] = folderId;
|
|
228
|
+
else delete assign[sessionName];
|
|
229
|
+
return { ...p, assign };
|
|
230
|
+
}),
|
|
231
|
+
[update],
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
const moveFolder = useCallback(
|
|
235
|
+
(id: string, parentId: string | null) =>
|
|
236
|
+
update((p) => {
|
|
237
|
+
if (id === parentId) return p;
|
|
238
|
+
if (parentId && isAncestor(p.folders, id, parentId)) return p;
|
|
239
|
+
return { ...p, folders: p.folders.map((f) => (f.id === id ? { ...f, parentId } : f)) };
|
|
240
|
+
}),
|
|
241
|
+
[update],
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
return { folders: slice.folders, folderOf, addFolder, renameFolder, deleteFolder, moveSession, moveFolder };
|
|
245
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { Terminal } from '@xterm/xterm';
|
|
2
|
+
import { FitAddon } from '@xterm/addon-fit';
|
|
3
|
+
import { WebLinksAddon } from '@xterm/addon-web-links';
|
|
4
|
+
|
|
5
|
+
export interface TerminalHandle {
|
|
6
|
+
term: Terminal;
|
|
7
|
+
fit: FitAddon;
|
|
8
|
+
/** Fit to the container and return the resulting geometry (or null if size 0). */
|
|
9
|
+
refit(): { cols: number; rows: number } | null;
|
|
10
|
+
dispose(): void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const THEME = {
|
|
14
|
+
background: '#000000',
|
|
15
|
+
foreground: '#c0caf5',
|
|
16
|
+
cursor: '#c0caf5',
|
|
17
|
+
selectionBackground: '#33467c',
|
|
18
|
+
black: '#15161e',
|
|
19
|
+
red: '#f7768e',
|
|
20
|
+
green: '#9ece6a',
|
|
21
|
+
yellow: '#e0af68',
|
|
22
|
+
blue: '#7aa2f7',
|
|
23
|
+
magenta: '#bb9af7',
|
|
24
|
+
cyan: '#7dcfff',
|
|
25
|
+
white: '#a9b1d6',
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** Create + open an xterm Terminal in `container`. Caller owns the lifecycle
|
|
29
|
+
* and must call dispose() (this is invoked from a single effect with a key, so
|
|
30
|
+
* React StrictMode's double-mount is handled by clean teardown). */
|
|
31
|
+
export function createTerminal(container: HTMLElement, fontSize: number): TerminalHandle {
|
|
32
|
+
const term = new Terminal({
|
|
33
|
+
fontFamily: 'Menlo, Consolas, "DejaVu Sans Mono", monospace',
|
|
34
|
+
fontSize,
|
|
35
|
+
theme: THEME,
|
|
36
|
+
scrollback: 5000,
|
|
37
|
+
cursorBlink: true,
|
|
38
|
+
allowProposedApi: true,
|
|
39
|
+
});
|
|
40
|
+
const fit = new FitAddon();
|
|
41
|
+
term.loadAddon(fit);
|
|
42
|
+
term.loadAddon(new WebLinksAddon());
|
|
43
|
+
term.open(container);
|
|
44
|
+
|
|
45
|
+
function refit(): { cols: number; rows: number } | null {
|
|
46
|
+
if (container.clientWidth === 0 || container.clientHeight === 0) return null;
|
|
47
|
+
try {
|
|
48
|
+
fit.fit();
|
|
49
|
+
} catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
return { cols: term.cols, rows: term.rows };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
term,
|
|
57
|
+
fit,
|
|
58
|
+
refit,
|
|
59
|
+
dispose() {
|
|
60
|
+
try {
|
|
61
|
+
term.dispose();
|
|
62
|
+
} catch {
|
|
63
|
+
/* ignore */
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { ServerControl } from '../types';
|
|
2
|
+
|
|
3
|
+
export interface TmuxSocket {
|
|
4
|
+
/** Send raw keystrokes (as a binary frame, per the server protocol). */
|
|
5
|
+
sendInput(data: string): void;
|
|
6
|
+
resize(cols: number, rows: number): void;
|
|
7
|
+
close(): void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface TmuxSocketCallbacks {
|
|
11
|
+
onOutput: (bytes: Uint8Array) => void;
|
|
12
|
+
onControl: (msg: ServerControl) => void;
|
|
13
|
+
onOpen: () => void;
|
|
14
|
+
onClose: (ev: CloseEvent) => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const encoder = new TextEncoder();
|
|
18
|
+
|
|
19
|
+
/** Open a WebSocket to the interactive-attach endpoint and wire the frame
|
|
20
|
+
* convention: binary frames = terminal bytes, text frames = JSON control. */
|
|
21
|
+
export function createTmuxSocket(url: string, cb: TmuxSocketCallbacks): TmuxSocket {
|
|
22
|
+
const ws = new WebSocket(url);
|
|
23
|
+
ws.binaryType = 'arraybuffer';
|
|
24
|
+
|
|
25
|
+
ws.onopen = () => cb.onOpen();
|
|
26
|
+
ws.onclose = (ev) => cb.onClose(ev);
|
|
27
|
+
ws.onmessage = (ev) => {
|
|
28
|
+
if (typeof ev.data === 'string') {
|
|
29
|
+
try {
|
|
30
|
+
cb.onControl(JSON.parse(ev.data) as ServerControl);
|
|
31
|
+
} catch {
|
|
32
|
+
/* ignore malformed control frame */
|
|
33
|
+
}
|
|
34
|
+
} else {
|
|
35
|
+
cb.onOutput(new Uint8Array(ev.data as ArrayBuffer));
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const sendJson = (obj: unknown) => {
|
|
40
|
+
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(obj));
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
sendInput(data) {
|
|
45
|
+
// Binary frame so the server routes it straight to the PTY.
|
|
46
|
+
if (ws.readyState === WebSocket.OPEN) ws.send(encoder.encode(data));
|
|
47
|
+
},
|
|
48
|
+
resize(cols, rows) {
|
|
49
|
+
sendJson({ type: 'resize', cols, rows });
|
|
50
|
+
},
|
|
51
|
+
close() {
|
|
52
|
+
// Drop handlers so a deliberate close doesn't surface as "disconnected".
|
|
53
|
+
ws.onclose = null;
|
|
54
|
+
ws.onmessage = null;
|
|
55
|
+
ws.onerror = null;
|
|
56
|
+
try {
|
|
57
|
+
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
|
|
58
|
+
ws.close(1000, 'client navigated');
|
|
59
|
+
}
|
|
60
|
+
} catch {
|
|
61
|
+
/* ignore */
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { useMemo } from 'react';
|
|
2
|
+
import { useSettings, type Language } from './settings';
|
|
3
|
+
import type { AttentionReason, SessionInfo } from './types';
|
|
4
|
+
|
|
5
|
+
export const TEXT = {
|
|
6
|
+
zh: {
|
|
7
|
+
settings: '设置',
|
|
8
|
+
fontSizes: '字体大小',
|
|
9
|
+
sidebar: '侧边栏',
|
|
10
|
+
terminal: '终端',
|
|
11
|
+
fileViewer: '文件查看器',
|
|
12
|
+
notifications: '提醒',
|
|
13
|
+
language: '语言',
|
|
14
|
+
chinese: '中文',
|
|
15
|
+
english: 'English',
|
|
16
|
+
alertWhenAgent: 'agent 结束或需要决策时提醒',
|
|
17
|
+
playSound: '播放提示音',
|
|
18
|
+
reset: '重置',
|
|
19
|
+
done: '完成',
|
|
20
|
+
smaller: '减小',
|
|
21
|
+
larger: '增大',
|
|
22
|
+
reconnect: '重连',
|
|
23
|
+
connecting: '正在连接...',
|
|
24
|
+
sessionEnded: '会话已结束。',
|
|
25
|
+
sessionEndedExit: (code: number) => `会话已结束,退出码 ${code}。`,
|
|
26
|
+
sshInterrupted: 'SSH 连接已中断。一次自动重连失败。',
|
|
27
|
+
disconnected: '连接已断开。',
|
|
28
|
+
failedLaunchAgent: '启动 agent 失败',
|
|
29
|
+
runClaude: '在当前 tmux 会话中启动已接入 hook 的 Claude Code',
|
|
30
|
+
runCodex: '在当前 tmux 会话中启动已接入 hook 的 Codex',
|
|
31
|
+
selectOrCreateSession: '选择或创建一个 tmux 会话',
|
|
32
|
+
pickSession: '在侧边栏选择会话以打开实时终端。',
|
|
33
|
+
dragResize: '拖动调整大小',
|
|
34
|
+
reloadTargets: '重新加载目标',
|
|
35
|
+
workingDirectory: '工作目录',
|
|
36
|
+
noTargets: '没有目标。',
|
|
37
|
+
noSessions: '没有会话。可在下方创建。',
|
|
38
|
+
failedLoadTargets: '加载目标失败',
|
|
39
|
+
failedListSessions: '列出会话失败',
|
|
40
|
+
invalidSessionName: '请使用字母、数字、_ 或 -,最多 64 个字符;不能包含 "." 或 ":"。',
|
|
41
|
+
failedCreateSession: '创建会话失败',
|
|
42
|
+
renameFailed: '重命名失败',
|
|
43
|
+
killFailed: '结束会话失败',
|
|
44
|
+
newFolder: '+ 文件夹',
|
|
45
|
+
newSession: '+ 会话',
|
|
46
|
+
newFolderTitle: '新建文件夹',
|
|
47
|
+
newSubfolder: '新建子文件夹',
|
|
48
|
+
renameFolder: '重命名文件夹',
|
|
49
|
+
deleteFolder: '删除文件夹(保留会话)',
|
|
50
|
+
folderDefaultName: '新建文件夹',
|
|
51
|
+
nameOptional: '名称(可选)',
|
|
52
|
+
commandOptional: '初始命令(可选)',
|
|
53
|
+
create: '创建',
|
|
54
|
+
cancel: '取消',
|
|
55
|
+
rename: '重命名',
|
|
56
|
+
kill: '结束',
|
|
57
|
+
killConfirm: (name: string) => `结束会话 "${name}"?这会终止其中的进程。`,
|
|
58
|
+
windowsShort: '窗',
|
|
59
|
+
selectSessionForFiles: '选择一个会话以浏览它的工作目录。',
|
|
60
|
+
fileBrowserUnavailable: '原生 shell 会话不支持文件浏览器。',
|
|
61
|
+
backToSessionDirectory: '返回会话目录',
|
|
62
|
+
upOneLevel: '上一级',
|
|
63
|
+
cannotListDirectory: '无法列出目录',
|
|
64
|
+
emptyDirectory: '空目录',
|
|
65
|
+
failedReadFile: '读取文件失败',
|
|
66
|
+
saveFailed: '保存失败',
|
|
67
|
+
discardUnsaved: '放弃未保存的更改?',
|
|
68
|
+
unsavedChanges: '未保存的更改',
|
|
69
|
+
undo: '撤销 (Ctrl/Cmd+Z)',
|
|
70
|
+
redo: '重做 (Ctrl/Cmd+Shift+Z)',
|
|
71
|
+
save: '保存',
|
|
72
|
+
saveShortcut: '保存 (Ctrl/Cmd+S)',
|
|
73
|
+
saving: '保存中...',
|
|
74
|
+
truncatedReadOnly: '内容已截断,只读',
|
|
75
|
+
closeFile: '关闭文件',
|
|
76
|
+
loading: '加载中...',
|
|
77
|
+
binaryNotShown: '二进制文件,未显示。',
|
|
78
|
+
attentionDecision: '决策',
|
|
79
|
+
attentionError: '错误',
|
|
80
|
+
attentionDone: '结束',
|
|
81
|
+
attentionDecisionTitle: '需要决策',
|
|
82
|
+
attentionErrorTitle: '异常停止',
|
|
83
|
+
attentionDoneTitle: '结束运行',
|
|
84
|
+
agentNotHooked: '未接入 agent hook',
|
|
85
|
+
agentRunning: (kind: string) => `${kind} 正在运行`,
|
|
86
|
+
agentWaiting: (kind: string) => `${kind} 需要决策`,
|
|
87
|
+
agentDone: (kind: string) => `${kind} 已结束运行`,
|
|
88
|
+
agentError: (kind: string) => `${kind} 异常停止`,
|
|
89
|
+
agentIdle: (kind: string) => `${kind} 空闲`,
|
|
90
|
+
justNow: '刚刚',
|
|
91
|
+
},
|
|
92
|
+
en: {
|
|
93
|
+
settings: 'Settings',
|
|
94
|
+
fontSizes: 'Font sizes',
|
|
95
|
+
sidebar: 'Sidebar',
|
|
96
|
+
terminal: 'Terminal',
|
|
97
|
+
fileViewer: 'File viewer',
|
|
98
|
+
notifications: 'Notifications',
|
|
99
|
+
language: 'Language',
|
|
100
|
+
chinese: '中文',
|
|
101
|
+
english: 'English',
|
|
102
|
+
alertWhenAgent: 'Alert when an agent finishes / asks',
|
|
103
|
+
playSound: 'Play a sound',
|
|
104
|
+
reset: 'Reset',
|
|
105
|
+
done: 'Done',
|
|
106
|
+
smaller: 'smaller',
|
|
107
|
+
larger: 'larger',
|
|
108
|
+
reconnect: 'Reconnect',
|
|
109
|
+
connecting: 'Connecting...',
|
|
110
|
+
sessionEnded: 'Session ended.',
|
|
111
|
+
sessionEndedExit: (code: number) => `Session ended (exit ${code}).`,
|
|
112
|
+
sshInterrupted: 'SSH connection interrupted. One reconnect attempt failed.',
|
|
113
|
+
disconnected: 'Disconnected.',
|
|
114
|
+
failedLaunchAgent: 'failed to launch agent',
|
|
115
|
+
runClaude: 'Run hooked Claude Code in this tmux session',
|
|
116
|
+
runCodex: 'Run hooked Codex in this tmux session',
|
|
117
|
+
selectOrCreateSession: 'Select or create a tmux session',
|
|
118
|
+
pickSession: 'Pick a session in the sidebar to open a live terminal.',
|
|
119
|
+
dragResize: 'Drag to resize',
|
|
120
|
+
reloadTargets: 'Reload targets',
|
|
121
|
+
workingDirectory: 'Working directory',
|
|
122
|
+
noTargets: 'No targets.',
|
|
123
|
+
noSessions: 'No sessions. Create one below.',
|
|
124
|
+
failedLoadTargets: 'failed to load targets',
|
|
125
|
+
failedListSessions: 'failed to list sessions',
|
|
126
|
+
invalidSessionName: 'Use letters, digits, _ or - (max 64). No "." or ":".',
|
|
127
|
+
failedCreateSession: 'failed to create session',
|
|
128
|
+
renameFailed: 'rename failed',
|
|
129
|
+
killFailed: 'kill failed',
|
|
130
|
+
newFolder: '+ folder',
|
|
131
|
+
newSession: '+ session',
|
|
132
|
+
newFolderTitle: 'New folder',
|
|
133
|
+
newSubfolder: 'New subfolder',
|
|
134
|
+
renameFolder: 'Rename folder',
|
|
135
|
+
deleteFolder: 'Delete folder (keeps sessions)',
|
|
136
|
+
folderDefaultName: 'New folder',
|
|
137
|
+
nameOptional: 'name (optional)',
|
|
138
|
+
commandOptional: 'initial command (optional)',
|
|
139
|
+
create: 'Create',
|
|
140
|
+
cancel: 'Cancel',
|
|
141
|
+
rename: 'Rename',
|
|
142
|
+
kill: 'Kill',
|
|
143
|
+
killConfirm: (name: string) => `Kill session "${name}"? This terminates its processes.`,
|
|
144
|
+
windowsShort: 'win',
|
|
145
|
+
selectSessionForFiles: 'Select a session to browse its working directory.',
|
|
146
|
+
fileBrowserUnavailable: "File browser isn't available for native shell sessions.",
|
|
147
|
+
backToSessionDirectory: 'Back to session directory',
|
|
148
|
+
upOneLevel: 'Up one level',
|
|
149
|
+
cannotListDirectory: 'cannot list directory',
|
|
150
|
+
emptyDirectory: 'empty directory',
|
|
151
|
+
failedReadFile: 'failed to read file',
|
|
152
|
+
saveFailed: 'save failed',
|
|
153
|
+
discardUnsaved: 'Discard unsaved changes?',
|
|
154
|
+
unsavedChanges: 'Unsaved changes',
|
|
155
|
+
undo: 'Undo (Ctrl/Cmd+Z)',
|
|
156
|
+
redo: 'Redo (Ctrl/Cmd+Shift+Z)',
|
|
157
|
+
save: 'Save',
|
|
158
|
+
saveShortcut: 'Save (Ctrl/Cmd+S)',
|
|
159
|
+
saving: 'Saving...',
|
|
160
|
+
truncatedReadOnly: 'truncated - read only',
|
|
161
|
+
closeFile: 'Close file',
|
|
162
|
+
loading: 'Loading...',
|
|
163
|
+
binaryNotShown: 'Binary file - not shown.',
|
|
164
|
+
attentionDecision: 'decision',
|
|
165
|
+
attentionError: 'error',
|
|
166
|
+
attentionDone: 'done',
|
|
167
|
+
attentionDecisionTitle: 'Needs decision',
|
|
168
|
+
attentionErrorTitle: 'Stopped abnormally',
|
|
169
|
+
attentionDoneTitle: 'Done',
|
|
170
|
+
agentNotHooked: 'agent hook not attached',
|
|
171
|
+
agentRunning: (kind: string) => `${kind} running`,
|
|
172
|
+
agentWaiting: (kind: string) => `${kind} needs decision`,
|
|
173
|
+
agentDone: (kind: string) => `${kind} done`,
|
|
174
|
+
agentError: (kind: string) => `${kind} stopped abnormally`,
|
|
175
|
+
agentIdle: (kind: string) => `${kind} idle`,
|
|
176
|
+
justNow: 'just now',
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
export type I18n = typeof TEXT.zh;
|
|
181
|
+
|
|
182
|
+
export function textFor(language: Language): I18n {
|
|
183
|
+
return TEXT[language];
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function useI18n(): { language: Language; t: I18n } {
|
|
187
|
+
const { settings } = useSettings();
|
|
188
|
+
return useMemo(
|
|
189
|
+
() => ({ language: settings.language, t: textFor(settings.language) }),
|
|
190
|
+
[settings.language],
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function attentionText(reason: AttentionReason, t: I18n): string {
|
|
195
|
+
if (reason === 'decision') return t.attentionDecision;
|
|
196
|
+
if (reason === 'error') return t.attentionError;
|
|
197
|
+
return t.attentionDone;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function attentionTitle(reason: AttentionReason, t: I18n): string {
|
|
201
|
+
if (reason === 'decision') return t.attentionDecisionTitle;
|
|
202
|
+
if (reason === 'error') return t.attentionErrorTitle;
|
|
203
|
+
return t.attentionDoneTitle;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function agentStatusLabel(session: SessionInfo, t: I18n): string {
|
|
207
|
+
if (!session.agentKind || !session.agentState) return t.agentNotHooked;
|
|
208
|
+
if (session.agentState === 'running') return t.agentRunning(session.agentKind);
|
|
209
|
+
if (session.agentState === 'waiting') return t.agentWaiting(session.agentKind);
|
|
210
|
+
if (session.attentionReason === 'done') return t.agentDone(session.agentKind);
|
|
211
|
+
if (session.attentionReason === 'error') return t.agentError(session.agentKind);
|
|
212
|
+
return t.agentIdle(session.agentKind);
|
|
213
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { StrictMode } from 'react';
|
|
2
|
+
import { createRoot } from 'react-dom/client';
|
|
3
|
+
import { App } from './App';
|
|
4
|
+
import { SettingsProvider } from './settings';
|
|
5
|
+
import { AttentionProvider } from './attention';
|
|
6
|
+
import '@xterm/xterm/css/xterm.css';
|
|
7
|
+
import './styles.css';
|
|
8
|
+
|
|
9
|
+
createRoot(document.getElementById('root')!).render(
|
|
10
|
+
<StrictMode>
|
|
11
|
+
<SettingsProvider>
|
|
12
|
+
<AttentionProvider>
|
|
13
|
+
<App />
|
|
14
|
+
</AttentionProvider>
|
|
15
|
+
</SettingsProvider>
|
|
16
|
+
</StrictMode>,
|
|
17
|
+
);
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
export type Language = 'zh' | 'en';
|
|
4
|
+
|
|
5
|
+
export interface Settings {
|
|
6
|
+
/** UI language. */
|
|
7
|
+
language: Language;
|
|
8
|
+
/** Sidebar (tmux tree + file explorer) font size, px. */
|
|
9
|
+
sidebarFontSize: number;
|
|
10
|
+
/** xterm terminal font size, px. */
|
|
11
|
+
terminalFontSize: number;
|
|
12
|
+
/** File viewer font size, px. */
|
|
13
|
+
viewerFontSize: number;
|
|
14
|
+
/** Notify when a supported agent finishes or needs a decision. */
|
|
15
|
+
notifyAttention: boolean;
|
|
16
|
+
/** Play a sound with the notification. */
|
|
17
|
+
notifySound: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const DEFAULT_SETTINGS: Settings = {
|
|
21
|
+
language: 'zh',
|
|
22
|
+
sidebarFontSize: 13,
|
|
23
|
+
terminalFontSize: 13,
|
|
24
|
+
viewerFontSize: 13,
|
|
25
|
+
notifyAttention: true,
|
|
26
|
+
notifySound: true,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export const FONT_LIMITS = { min: 8, max: 28 };
|
|
30
|
+
|
|
31
|
+
const STORAGE_KEY = 'tmuxes.settings';
|
|
32
|
+
|
|
33
|
+
interface SettingsContextValue {
|
|
34
|
+
settings: Settings;
|
|
35
|
+
setSetting: <K extends keyof Settings>(key: K, value: Settings[K]) => void;
|
|
36
|
+
reset: () => void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const SettingsContext = createContext<SettingsContextValue | null>(null);
|
|
40
|
+
|
|
41
|
+
function coerceSettings(value: Partial<Settings>): Settings {
|
|
42
|
+
const language = value.language === 'en' ? 'en' : 'zh';
|
|
43
|
+
return {
|
|
44
|
+
...DEFAULT_SETTINGS,
|
|
45
|
+
...value,
|
|
46
|
+
language,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function load(): Settings {
|
|
51
|
+
try {
|
|
52
|
+
const raw = localStorage.getItem(STORAGE_KEY);
|
|
53
|
+
if (raw) return coerceSettings(JSON.parse(raw) as Partial<Settings>);
|
|
54
|
+
} catch {
|
|
55
|
+
/* ignore */
|
|
56
|
+
}
|
|
57
|
+
return DEFAULT_SETTINGS;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const clamp = (n: number) => Math.min(FONT_LIMITS.max, Math.max(FONT_LIMITS.min, Math.round(n)));
|
|
61
|
+
|
|
62
|
+
export function SettingsProvider({ children }: { children: ReactNode }) {
|
|
63
|
+
const [settings, setSettings] = useState<Settings>(load);
|
|
64
|
+
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
try {
|
|
67
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
|
|
68
|
+
} catch {
|
|
69
|
+
/* ignore */
|
|
70
|
+
}
|
|
71
|
+
}, [settings]);
|
|
72
|
+
|
|
73
|
+
const setSetting = useCallback<SettingsContextValue['setSetting']>((key, value) => {
|
|
74
|
+
setSettings((s) => ({ ...s, [key]: typeof value === 'number' ? clamp(value) : value }));
|
|
75
|
+
}, []);
|
|
76
|
+
|
|
77
|
+
const reset = useCallback(() => setSettings(DEFAULT_SETTINGS), []);
|
|
78
|
+
|
|
79
|
+
const value = useMemo(() => ({ settings, setSetting, reset }), [settings, setSetting, reset]);
|
|
80
|
+
return <SettingsContext.Provider value={value}>{children}</SettingsContext.Provider>;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function useSettings(): SettingsContextValue {
|
|
84
|
+
const ctx = useContext(SettingsContext);
|
|
85
|
+
if (!ctx) throw new Error('useSettings must be used within SettingsProvider');
|
|
86
|
+
return ctx;
|
|
87
|
+
}
|