tmuxes 0.1.8 → 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.
Files changed (140) hide show
  1. package/.node-version +1 -0
  2. package/.nvmrc +1 -0
  3. package/.tmp-npm-cache/_cacache/content-v2/sha512/43/27/5e000b8b9c56a6ccc66f709485499f4304e2cb1982582ba571321c07b3ef56fcabd2c671898cc8003365a0485b6fd8e73e7b17b073cec0f7d1628c1a99df +0 -0
  4. package/.tmp-npm-cache/_cacache/content-v2/sha512/51/cf/4301295d74559ed494bae160d54d8741077f89faebb311882ac065019246951e7b53f3dcb913793c42b331e14c7070c4810c3cdc27a427d103a7db4614e0 +0 -0
  5. package/.tmp-npm-cache/_cacache/content-v2/sha512/c3/4d/d68a454a916e74c2617f586fbf770981b33811d667c2547eb0e9fc21938f4ee7e98f1ceee4bde8ad8815b5f6efe21b60eee798837d68f51a3340d7e5bb7a +0 -0
  6. package/.tmp-npm-cache/_cacache/content-v2/sha512/fe/40/2abfbefc96299e8bf714aa91d62607190ae299e102cf5933db2e2904640d65d25d67dbbb6fa2ddc92a17f00b9dbfdf2e37487f67d96ec36c64a285b59a7d +0 -0
  7. package/.tmp-npm-cache/_cacache/index-v5/27/fe/81a3de6ce7ae3d1e41a3421de20c5629998c4ee5d0ffe2037630f03b03b2 +4 -0
  8. package/.tmp-npm-cache/_cacache/index-v5/65/22/dd66711f62681fce09aabb2357a2907b4a0c778ac5227c4baf9603fd86e8 +4 -0
  9. package/.tmp-npm-cache/_update-notifier-last-checked +0 -0
  10. package/AGENTS.md +15 -0
  11. package/CLAUDE.md +3 -0
  12. package/LICENSE +21 -21
  13. package/README.en.md +304 -0
  14. package/README.md +299 -295
  15. package/SECURITY.md +31 -0
  16. package/{public → client}/index.html +12 -13
  17. package/client/package.json +29 -0
  18. package/client/src/App.tsx +123 -0
  19. package/client/src/activity.ts +5 -0
  20. package/client/src/api.ts +130 -0
  21. package/client/src/attention.tsx +157 -0
  22. package/client/src/components/FileExplorer.tsx +156 -0
  23. package/client/src/components/FileViewer.tsx +194 -0
  24. package/client/src/components/SessionRow.tsx +108 -0
  25. package/client/src/components/SessionTree.tsx +197 -0
  26. package/client/src/components/SettingsButton.tsx +122 -0
  27. package/client/src/components/Sidebar.tsx +96 -0
  28. package/client/src/components/StatusBanner.tsx +31 -0
  29. package/client/src/components/TargetGroup.tsx +275 -0
  30. package/client/src/components/TerminalPanel.tsx +192 -0
  31. package/client/src/folders.ts +245 -0
  32. package/client/src/hooks/useTerminal.ts +67 -0
  33. package/client/src/hooks/useTmuxSocket.ts +65 -0
  34. package/client/src/i18n.ts +213 -0
  35. package/client/src/main.tsx +17 -0
  36. package/client/src/settings.tsx +87 -0
  37. package/client/src/styles.css +723 -0
  38. package/client/src/types.ts +93 -0
  39. package/client/src/util.ts +65 -0
  40. package/client/tsconfig.json +13 -0
  41. package/client/vite.config.ts +15 -0
  42. package/fig/fig1.png +0 -0
  43. package/package.json +28 -61
  44. package/scripts/prepack.mjs +35 -0
  45. package/{bin → server/bin}/tmuxes.js +36 -36
  46. package/server/package.json +61 -0
  47. package/server/src/agentHooks.ts +120 -0
  48. package/server/src/agentOutput.ts +36 -0
  49. package/server/src/agentState.ts +70 -0
  50. package/server/src/config.ts +31 -0
  51. package/server/src/exe.ts +34 -0
  52. package/server/src/exec.ts +61 -0
  53. package/server/src/files.ts +330 -0
  54. package/server/src/foldersStore.ts +114 -0
  55. package/server/src/index.ts +114 -0
  56. package/server/src/logger.ts +16 -0
  57. package/{dist/monitor.js → server/src/monitor.ts} +10 -9
  58. package/server/src/openBrowser.ts +28 -0
  59. package/{dist/platform.js → server/src/platform.ts} +4 -5
  60. package/server/src/rest/router.ts +290 -0
  61. package/server/src/targetCommand.ts +79 -0
  62. package/server/src/targets.ts +152 -0
  63. package/server/src/tmux/builder.ts +198 -0
  64. package/server/src/tmux/formats.ts +95 -0
  65. package/server/src/tmux/sessions.ts +204 -0
  66. package/server/src/validate.ts +79 -0
  67. package/server/src/windowsSsh.ts +239 -0
  68. package/server/src/winshell/manager.ts +296 -0
  69. package/server/src/ws/protocol.ts +15 -0
  70. package/server/src/ws/sshState.ts +36 -0
  71. package/server/src/ws/terminalSession.ts +207 -0
  72. package/server/src/ws/wsServer.ts +153 -0
  73. package/server/src/wsl.ts +38 -0
  74. package/server/test/agentHooks.test.ts +66 -0
  75. package/server/test/agentOutput.test.ts +26 -0
  76. package/server/test/agentState.test.ts +24 -0
  77. package/server/test/builder.test.ts +162 -0
  78. package/server/test/files.test.ts +81 -0
  79. package/server/test/formats.test.ts +123 -0
  80. package/server/test/monitor.test.ts +25 -0
  81. package/server/test/validate.test.ts +71 -0
  82. package/server/test/wsl.test.ts +18 -0
  83. package/server/tsconfig.json +9 -0
  84. package/server/vitest.config.ts +12 -0
  85. package/start.cmd +30 -0
  86. package/start.command +20 -0
  87. package/start.sh +20 -0
  88. package/tsconfig.base.json +19 -0
  89. package/dist/agentHooks.js +0 -91
  90. package/dist/agentHooks.js.map +0 -1
  91. package/dist/agentOutput.js +0 -30
  92. package/dist/agentOutput.js.map +0 -1
  93. package/dist/agentState.js +0 -45
  94. package/dist/agentState.js.map +0 -1
  95. package/dist/config.js +0 -32
  96. package/dist/config.js.map +0 -1
  97. package/dist/exe.js +0 -37
  98. package/dist/exe.js.map +0 -1
  99. package/dist/exec.js +0 -43
  100. package/dist/exec.js.map +0 -1
  101. package/dist/files.js +0 -243
  102. package/dist/files.js.map +0 -1
  103. package/dist/foldersStore.js +0 -103
  104. package/dist/foldersStore.js.map +0 -1
  105. package/dist/index.js +0 -117
  106. package/dist/index.js.map +0 -1
  107. package/dist/logger.js +0 -16
  108. package/dist/logger.js.map +0 -1
  109. package/dist/monitor.js.map +0 -1
  110. package/dist/openBrowser.js +0 -31
  111. package/dist/openBrowser.js.map +0 -1
  112. package/dist/platform.js.map +0 -1
  113. package/dist/rest/router.js +0 -190
  114. package/dist/rest/router.js.map +0 -1
  115. package/dist/targetCommand.js +0 -41
  116. package/dist/targetCommand.js.map +0 -1
  117. package/dist/targets.js +0 -131
  118. package/dist/targets.js.map +0 -1
  119. package/dist/tmux/builder.js +0 -173
  120. package/dist/tmux/builder.js.map +0 -1
  121. package/dist/tmux/formats.js +0 -61
  122. package/dist/tmux/formats.js.map +0 -1
  123. package/dist/tmux/sessions.js +0 -157
  124. package/dist/tmux/sessions.js.map +0 -1
  125. package/dist/validate.js +0 -65
  126. package/dist/validate.js.map +0 -1
  127. package/dist/winshell/manager.js +0 -267
  128. package/dist/winshell/manager.js.map +0 -1
  129. package/dist/ws/protocol.js +0 -4
  130. package/dist/ws/protocol.js.map +0 -1
  131. package/dist/ws/sshState.js +0 -35
  132. package/dist/ws/sshState.js.map +0 -1
  133. package/dist/ws/terminalSession.js +0 -204
  134. package/dist/ws/terminalSession.js.map +0 -1
  135. package/dist/ws/wsServer.js +0 -151
  136. package/dist/ws/wsServer.js.map +0 -1
  137. package/dist/wsl.js +0 -35
  138. package/dist/wsl.js.map +0 -1
  139. package/public/assets/index-BpVrfoZw.js +0 -44
  140. package/public/assets/index-D_X5SnGx.css +0 -1
package/SECURITY.md ADDED
@@ -0,0 +1,31 @@
1
+ # Security Policy
2
+
3
+ tmuxes is a localhost-only, single-user developer tool.
4
+
5
+ It intentionally gives the browser UI access to live tmux sessions, keyboard input, and files inside a selected session's current working directory. Treat anyone who can reach the HTTP server as having shell-level access to the target machine or SSH host.
6
+
7
+ ## Supported Use
8
+
9
+ - Run tmuxes only on a trusted workstation.
10
+ - The server binds to `127.0.0.1` only. Do not reverse-proxy, tunnel, port-forward, or otherwise expose it to a network.
11
+ - tmuxes has no authentication by design.
12
+ - SSH targets use your existing system `ssh` configuration, keys, and agent. tmuxes does not store SSH passwords or private keys.
13
+ - The file browser/editor is scoped to the selected tmux session's current working directory.
14
+
15
+ ## Unsupported Use
16
+
17
+ - Binding to `0.0.0.0` or a LAN/public interface.
18
+ - Running tmuxes as a shared multi-user service.
19
+ - Exposing tmuxes through ngrok, cloudflared, SSH remote forwards, reverse proxies, or container port publishing.
20
+ - Treating tmuxes as a hardened remote administration system.
21
+
22
+ ## Reporting a Vulnerability
23
+
24
+ Please open a private security advisory on GitHub if the repository is hosted there. If advisories are not enabled, contact the maintainer privately before publishing details.
25
+
26
+ Useful reports include:
27
+
28
+ - Steps to reproduce.
29
+ - Affected platform and Node.js version.
30
+ - Whether the issue affects local, WSL, or SSH targets.
31
+ - The expected security boundary and how it was bypassed.
@@ -1,13 +1,12 @@
1
- <!doctype html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="UTF-8" />
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
- <title>tmuxes</title>
7
- <script type="module" crossorigin src="/assets/index-BpVrfoZw.js"></script>
8
- <link rel="stylesheet" crossorigin href="/assets/index-D_X5SnGx.css">
9
- </head>
10
- <body>
11
- <div id="root"></div>
12
- </body>
13
- </html>
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>tmuxes</title>
7
+ </head>
8
+ <body>
9
+ <div id="root"></div>
10
+ <script type="module" src="/src/main.tsx"></script>
11
+ </body>
12
+ </html>
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "@tmuxes/client",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "engines": {
8
+ "node": ">=22.12.0 <23"
9
+ },
10
+ "scripts": {
11
+ "dev": "vite",
12
+ "build": "tsc --noEmit && vite build",
13
+ "preview": "vite preview"
14
+ },
15
+ "dependencies": {
16
+ "@xterm/addon-fit": "^0.11.0",
17
+ "@xterm/addon-web-links": "^0.12.0",
18
+ "@xterm/xterm": "^6.0.0",
19
+ "react": "^19.0.0",
20
+ "react-dom": "^19.0.0"
21
+ },
22
+ "devDependencies": {
23
+ "@types/react": "^19.0.1",
24
+ "@types/react-dom": "^19.0.2",
25
+ "@vitejs/plugin-react": "^5.0.0",
26
+ "typescript": "^5.7.2",
27
+ "vite": "^8.0.0"
28
+ }
29
+ }
@@ -0,0 +1,123 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+ import { api, ApiError } from './api';
3
+ import type { OpenFile, Selection, Target } from './types';
4
+ import { useSettings } from './settings';
5
+ import { useAttention } from './attention';
6
+ import { useI18n } from './i18n';
7
+ import { Sidebar } from './components/Sidebar';
8
+ import { TerminalPanel } from './components/TerminalPanel';
9
+ import { FileViewer } from './components/FileViewer';
10
+
11
+ export function App() {
12
+ const { settings } = useSettings();
13
+ const { t } = useI18n();
14
+ const attention = useAttention();
15
+ const [targets, setTargets] = useState<Target[]>([]);
16
+ const [loadError, setLoadError] = useState<string | null>(null);
17
+ const [selection, setSelection] = useState<Selection | null>(null);
18
+ const [openFile, setOpenFile] = useState<OpenFile | null>(null);
19
+ const [nowMs, setNowMs] = useState(() => Date.now());
20
+
21
+ const [viewerHeight, setViewerHeight] = useState(300);
22
+ const dragState = useRef<{ startY: number; startH: number } | null>(null);
23
+
24
+ const loadTargets = useCallback(async () => {
25
+ try {
26
+ const { targets } = await api.getTargets();
27
+ setTargets(targets);
28
+ setLoadError(null);
29
+ } catch (e) {
30
+ setLoadError(e instanceof ApiError ? e.message : t.failedLoadTargets);
31
+ }
32
+ }, [t.failedLoadTargets]);
33
+
34
+ useEffect(() => {
35
+ void loadTargets();
36
+ }, [loadTargets]);
37
+
38
+ // Switching sessions changes the file context — close any open file.
39
+ useEffect(() => {
40
+ setOpenFile(null);
41
+ }, [selection?.targetId, selection?.session]);
42
+
43
+ // Tell the attention tracker which session is in view, so it acknowledges
44
+ // (clears) that session's badge and won't alert the one you're watching.
45
+ useEffect(() => {
46
+ attention.setActive(selection?.targetId ?? null, selection?.session ?? null);
47
+ }, [attention, selection?.targetId, selection?.session]);
48
+
49
+ // Tick for relative "created" times in the sidebar.
50
+ useEffect(() => {
51
+ const id = window.setInterval(() => setNowMs(Date.now()), 30_000);
52
+ return () => window.clearInterval(id);
53
+ }, []);
54
+
55
+ const onViewerDividerDown = useCallback(
56
+ (e: React.MouseEvent) => {
57
+ e.preventDefault();
58
+ dragState.current = { startY: e.clientY, startH: viewerHeight };
59
+ const onMove = (ev: MouseEvent) => {
60
+ if (!dragState.current) return;
61
+ const delta = dragState.current.startY - ev.clientY; // drag up → taller viewer
62
+ const max = window.innerHeight - 160;
63
+ setViewerHeight(Math.max(100, Math.min(max, dragState.current.startH + delta)));
64
+ };
65
+ const onUp = () => {
66
+ dragState.current = null;
67
+ window.removeEventListener('mousemove', onMove);
68
+ window.removeEventListener('mouseup', onUp);
69
+ };
70
+ window.addEventListener('mousemove', onMove);
71
+ window.addEventListener('mouseup', onUp);
72
+ },
73
+ [viewerHeight],
74
+ );
75
+
76
+ const selectedTarget = selection ? targets.find((t) => t.id === selection.targetId) : undefined;
77
+
78
+ return (
79
+ <div className="app">
80
+ <Sidebar
81
+ targets={targets}
82
+ selection={selection}
83
+ nowMs={nowMs}
84
+ loadError={loadError}
85
+ openFile={openFile}
86
+ select={setSelection}
87
+ onRefreshTargets={() => void loadTargets()}
88
+ onOpenFile={setOpenFile}
89
+ />
90
+
91
+ <div className="workspace">
92
+ <div className="term-region">
93
+ {selection && selectedTarget ? (
94
+ <TerminalPanel
95
+ key={`${selection.targetId}/${selection.session}`}
96
+ targetId={selection.targetId}
97
+ targetKind={selectedTarget.kind}
98
+ targetLabel={selectedTarget.label}
99
+ session={selection.session}
100
+ fontSize={settings.terminalFontSize}
101
+ />
102
+ ) : (
103
+ <div className="panel">
104
+ <div className="panel-placeholder">
105
+ <div style={{ fontSize: 18 }}>{t.selectOrCreateSession}</div>
106
+ <div>{t.pickSession}</div>
107
+ </div>
108
+ </div>
109
+ )}
110
+ </div>
111
+
112
+ {openFile && (
113
+ <>
114
+ <div className="hdivider" onMouseDown={onViewerDividerDown} title={t.dragResize} />
115
+ <div className="viewer-region" style={{ height: viewerHeight }}>
116
+ <FileViewer file={openFile} onClose={() => setOpenFile(null)} />
117
+ </div>
118
+ </>
119
+ )}
120
+ </div>
121
+ </div>
122
+ );
123
+ }
@@ -0,0 +1,5 @@
1
+ import type { SessionInfo } from './types';
2
+
3
+ export function isSessionActive(session: SessionInfo): boolean {
4
+ return session.agentState === 'running';
5
+ }
@@ -0,0 +1,130 @@
1
+ import type {
2
+ FileEntry,
3
+ FilePreview,
4
+ LaunchAgent,
5
+ SessionDirectory,
6
+ SessionInfo,
7
+ Target,
8
+ WindowInfo,
9
+ } from './types';
10
+
11
+ async function request<T>(path: string, init?: RequestInit): Promise<T> {
12
+ const res = await fetch(path, {
13
+ headers: { 'Content-Type': 'application/json' },
14
+ ...init,
15
+ });
16
+ if (!res.ok) {
17
+ let message = `${res.status} ${res.statusText}`;
18
+ try {
19
+ const body = (await res.json()) as { error?: string };
20
+ if (body.error) message = body.error;
21
+ } catch {
22
+ /* non-JSON error body */
23
+ }
24
+ throw new ApiError(res.status, message);
25
+ }
26
+ if (res.status === 204) return undefined as T;
27
+ return (await res.json()) as T;
28
+ }
29
+
30
+ export class ApiError extends Error {
31
+ constructor(
32
+ public status: number,
33
+ message: string,
34
+ ) {
35
+ super(message);
36
+ this.name = 'ApiError';
37
+ }
38
+ }
39
+
40
+ export const api = {
41
+ getTargets(): Promise<{ targets: Target[] }> {
42
+ return request('/api/targets');
43
+ },
44
+ getSessions(targetId: string): Promise<{ sessions: SessionInfo[] }> {
45
+ return request(`/api/targets/${encodeURIComponent(targetId)}/sessions`);
46
+ },
47
+ createSession(
48
+ targetId: string,
49
+ body: { name?: string; command?: string; shell?: string },
50
+ ): Promise<{ name: string }> {
51
+ return request(`/api/targets/${encodeURIComponent(targetId)}/sessions`, {
52
+ method: 'POST',
53
+ body: JSON.stringify(body),
54
+ });
55
+ },
56
+ renameSession(targetId: string, name: string, newName: string): Promise<{ name: string }> {
57
+ return request(
58
+ `/api/targets/${encodeURIComponent(targetId)}/sessions/${encodeURIComponent(name)}`,
59
+ { method: 'PATCH', body: JSON.stringify({ newName }) },
60
+ );
61
+ },
62
+ killSession(targetId: string, name: string): Promise<void> {
63
+ return request(
64
+ `/api/targets/${encodeURIComponent(targetId)}/sessions/${encodeURIComponent(name)}`,
65
+ { method: 'DELETE' },
66
+ );
67
+ },
68
+ launchAgent(targetId: string, name: string, agent: LaunchAgent): Promise<{ ok: true }> {
69
+ return request(
70
+ `/api/targets/${encodeURIComponent(targetId)}/sessions/${encodeURIComponent(name)}/agent`,
71
+ { method: 'POST', body: JSON.stringify({ agent }) },
72
+ );
73
+ },
74
+ getWindows(targetId: string, name: string): Promise<{ windows: WindowInfo[] }> {
75
+ return request(
76
+ `/api/targets/${encodeURIComponent(targetId)}/sessions/${encodeURIComponent(name)}/windows`,
77
+ );
78
+ },
79
+ getCwd(targetId: string, name: string): Promise<{ cwd: string }> {
80
+ return request(
81
+ `/api/targets/${encodeURIComponent(targetId)}/sessions/${encodeURIComponent(name)}/cwd`,
82
+ );
83
+ },
84
+ listFiles(targetId: string, session: string, path: string): Promise<{ path: string; entries: FileEntry[] }> {
85
+ return request(
86
+ `/api/targets/${encodeURIComponent(targetId)}/files?session=${encodeURIComponent(session)}&path=${encodeURIComponent(path)}`,
87
+ );
88
+ },
89
+ getSessionFiles(targetId: string, session: string, path?: string): Promise<SessionDirectory> {
90
+ const qs = path === undefined ? '' : `?path=${encodeURIComponent(path)}`;
91
+ return request(
92
+ `/api/targets/${encodeURIComponent(targetId)}/sessions/${encodeURIComponent(session)}/files${qs}`,
93
+ );
94
+ },
95
+ getFile(targetId: string, session: string, path: string): Promise<FilePreview> {
96
+ return request(
97
+ `/api/targets/${encodeURIComponent(targetId)}/file?session=${encodeURIComponent(session)}&path=${encodeURIComponent(path)}`,
98
+ );
99
+ },
100
+ saveFile(targetId: string, session: string, path: string, content: string): Promise<{ ok: true }> {
101
+ return request(`/api/targets/${encodeURIComponent(targetId)}/file`, {
102
+ method: 'PUT',
103
+ body: JSON.stringify({ session, path, content }),
104
+ });
105
+ },
106
+ getFolders(targetId: string): Promise<{ folders: unknown[]; assign: Record<string, unknown> }> {
107
+ return request(`/api/targets/${encodeURIComponent(targetId)}/folders`);
108
+ },
109
+ saveFolders(
110
+ targetId: string,
111
+ payload: { folders: unknown[]; assign: Record<string, string> },
112
+ ): Promise<{ ok: true }> {
113
+ return request(`/api/targets/${encodeURIComponent(targetId)}/folders`, {
114
+ method: 'PUT',
115
+ body: JSON.stringify(payload),
116
+ });
117
+ },
118
+ };
119
+
120
+ /** Build the WebSocket URL for an interactive attach (same-origin). */
121
+ export function terminalSocketUrl(targetId: string, session: string, cols: number, rows: number): string {
122
+ const proto = location.protocol === 'https:' ? 'wss' : 'ws';
123
+ const params = new URLSearchParams({
124
+ target: targetId,
125
+ session,
126
+ cols: String(cols),
127
+ rows: String(rows),
128
+ });
129
+ return `${proto}://${location.host}/ws?${params.toString()}`;
130
+ }
@@ -0,0 +1,157 @@
1
+ import {
2
+ createContext,
3
+ useCallback,
4
+ useContext,
5
+ useEffect,
6
+ useMemo,
7
+ useRef,
8
+ useState,
9
+ type ReactNode,
10
+ } from 'react';
11
+ import { useSettings } from './settings';
12
+ import type { AttentionReason } from './types';
13
+
14
+ /** Tracks sessions that need attention after an agent hook event and delivers
15
+ * the notification: a sidebar highlight, a short sound, and a flashing tab
16
+ * title while the tab is in the background. */
17
+ interface AttentionApi {
18
+ /** Current badge reason for a session, if any. */
19
+ reasonFor: (targetId: string, session: string) => AttentionReason | undefined;
20
+ /** Raise attention for a session. */
21
+ fire: (targetId: string, session: string, reason: AttentionReason) => void;
22
+ /** A session became active again — drop its badge. */
23
+ clearAlert: (targetId: string, session: string) => void;
24
+ /** The user is now looking at this session — acknowledge its alert. */
25
+ setActive: (targetId: string | null, session: string | null) => void;
26
+ }
27
+
28
+ const AttentionContext = createContext<AttentionApi | null>(null);
29
+
30
+ const keyOf = (targetId: string, session: string) => `${targetId}\u0000${session}`;
31
+
32
+ let audioCtx: AudioContext | null = null;
33
+ function playPing(): void {
34
+ try {
35
+ const Ctor = window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
36
+ if (!Ctor) return;
37
+ audioCtx ??= new Ctor();
38
+ const ctx = audioCtx;
39
+ if (ctx.state === 'suspended') void ctx.resume();
40
+ const osc = ctx.createOscillator();
41
+ const gain = ctx.createGain();
42
+ osc.connect(gain);
43
+ gain.connect(ctx.destination);
44
+ osc.type = 'sine';
45
+ osc.frequency.value = 880;
46
+ const t = ctx.currentTime;
47
+ gain.gain.setValueAtTime(0.0001, t);
48
+ gain.gain.exponentialRampToValueAtTime(0.18, t + 0.01);
49
+ gain.gain.exponentialRampToValueAtTime(0.0001, t + 0.28);
50
+ osc.start(t);
51
+ osc.stop(t + 0.3);
52
+ } catch {
53
+ /* audio not available / blocked before a gesture — ignore */
54
+ }
55
+ }
56
+
57
+ export function AttentionProvider({ children }: { children: ReactNode }) {
58
+ const { settings } = useSettings();
59
+ const [alerts, setAlerts] = useState<Record<string, AttentionReason>>({});
60
+ const activeKey = useRef<string | null>(null);
61
+ // Keep the latest sound preference readable inside async callbacks.
62
+ const soundOn = useRef(settings.notifySound);
63
+ soundOn.current = settings.notifySound;
64
+
65
+ const fire = useCallback<AttentionApi['fire']>((targetId, session, reason) => {
66
+ if (!settings.notifyAttention) return;
67
+ const key = keyOf(targetId, session);
68
+ // The user is already watching this session in the foreground — no need.
69
+ if (key === activeKey.current && !document.hidden) return;
70
+
71
+ setAlerts((prev) => ({ ...prev, [key]: reason }));
72
+ if (soundOn.current) playPing();
73
+ }, [settings.notifyAttention]);
74
+
75
+ const clearAlert = useCallback<AttentionApi['clearAlert']>((targetId, session) => {
76
+ const key = keyOf(targetId, session);
77
+ setAlerts((prev) => {
78
+ if (!(key in prev)) return prev;
79
+ const next = { ...prev };
80
+ delete next[key];
81
+ return next;
82
+ });
83
+ }, []);
84
+
85
+ const setActive = useCallback<AttentionApi['setActive']>((targetId, session) => {
86
+ const key = targetId && session ? keyOf(targetId, session) : null;
87
+ activeKey.current = key;
88
+ if (!key) return;
89
+ setAlerts((prev) => {
90
+ if (!(key in prev)) return prev;
91
+ const next = { ...prev };
92
+ delete next[key];
93
+ return next;
94
+ });
95
+ }, []);
96
+
97
+ const reasonFor = useCallback<AttentionApi['reasonFor']>(
98
+ (targetId, session) => alerts[keyOf(targetId, session)],
99
+ [alerts],
100
+ );
101
+
102
+ // Flash the tab title while there are pending alerts and the tab is hidden.
103
+ useEffect(() => {
104
+ const count = Object.keys(alerts).length;
105
+ const base = 'tmuxes';
106
+ if (count === 0) {
107
+ document.title = base;
108
+ return;
109
+ }
110
+ let on = false;
111
+ const render = () => {
112
+ if (document.hidden) {
113
+ on = !on;
114
+ document.title = on ? `🔔 (${count}) ${base}` : base;
115
+ } else {
116
+ document.title = base;
117
+ }
118
+ };
119
+ render();
120
+ const id = window.setInterval(render, 1000);
121
+ const onVis = () => {
122
+ if (!document.hidden) {
123
+ // Foreground again: stop flashing; acknowledge whatever is being viewed.
124
+ document.title = base;
125
+ if (activeKey.current) {
126
+ const key = activeKey.current;
127
+ setAlerts((prev) => {
128
+ if (!(key in prev)) return prev;
129
+ const next = { ...prev };
130
+ delete next[key];
131
+ return next;
132
+ });
133
+ }
134
+ } else {
135
+ render();
136
+ }
137
+ };
138
+ document.addEventListener('visibilitychange', onVis);
139
+ return () => {
140
+ window.clearInterval(id);
141
+ document.removeEventListener('visibilitychange', onVis);
142
+ document.title = base;
143
+ };
144
+ }, [alerts]);
145
+
146
+ const value = useMemo<AttentionApi>(
147
+ () => ({ reasonFor, fire, clearAlert, setActive }),
148
+ [reasonFor, fire, clearAlert, setActive],
149
+ );
150
+ return <AttentionContext.Provider value={value}>{children}</AttentionContext.Provider>;
151
+ }
152
+
153
+ export function useAttention(): AttentionApi {
154
+ const ctx = useContext(AttentionContext);
155
+ if (!ctx) throw new Error('useAttention must be used within AttentionProvider');
156
+ return ctx;
157
+ }
@@ -0,0 +1,156 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+ import type { FileEntry, OpenFile, Selection } from '../types';
3
+ import { api, ApiError } from '../api';
4
+ import { basename, isTextFile, joinPath, parentPath } from '../util';
5
+ import { useI18n } from '../i18n';
6
+
7
+ interface Props {
8
+ selection: Selection | null;
9
+ openFile: OpenFile | null;
10
+ /** Native shell sessions have no tmux cwd — disables the file browser. */
11
+ enabled: boolean;
12
+ /** Pause automatic polling after SSH errors to avoid repeated failed attempts. */
13
+ pauseOnError: boolean;
14
+ onOpenFile: (file: OpenFile) => void;
15
+ }
16
+
17
+ const POLL_MS = 3000;
18
+
19
+ function samePath(a: string | null, b: string | null): boolean {
20
+ if (!a || !b) return false;
21
+ const clean = (p: string) => (p === '/' ? p : p.replace(/\/+$/, ''));
22
+ return clean(a) === clean(b);
23
+ }
24
+
25
+ /** Bottom of the sidebar: the files/folders of the selected session's current
26
+ * working directory. Follows the session's cwd until you navigate away. */
27
+ export function FileExplorer({ selection, openFile, enabled, pauseOnError, onOpenFile }: Props) {
28
+ const { t } = useI18n();
29
+ const [cwd, setCwd] = useState<string | null>(null);
30
+ const [manualPath, setManualPath] = useState<string | null>(null);
31
+ const [entries, setEntries] = useState<FileEntry[]>([]);
32
+ const [error, setError] = useState<string | null>(null);
33
+ const pollingPaused = useRef(false);
34
+
35
+ const path = manualPath ?? cwd;
36
+ const following = manualPath === null;
37
+ const targetId = selection?.targetId;
38
+ const session = selection?.session;
39
+
40
+ // New selection → re-follow that session's cwd.
41
+ useEffect(() => {
42
+ setManualPath(null);
43
+ setCwd(null);
44
+ setEntries([]);
45
+ setError(null);
46
+ pollingPaused.current = false;
47
+ }, [targetId, session]);
48
+
49
+ const tick = useCallback(async () => {
50
+ if (pollingPaused.current) return;
51
+ if (!targetId || !session) return;
52
+ try {
53
+ const { cwd: latest, entries } = await api.getSessionFiles(
54
+ targetId,
55
+ session,
56
+ manualPath ?? undefined,
57
+ );
58
+ setCwd(latest);
59
+ setEntries(entries);
60
+ setError(null);
61
+ } catch (e) {
62
+ setError(e instanceof ApiError ? e.message : t.cannotListDirectory);
63
+ if (pauseOnError) pollingPaused.current = true;
64
+ }
65
+ }, [targetId, session, manualPath, pauseOnError, t.cannotListDirectory]);
66
+
67
+ const manualReconnect = useCallback(() => {
68
+ pollingPaused.current = false;
69
+ setError(null);
70
+ void tick();
71
+ }, [tick]);
72
+
73
+ useEffect(() => {
74
+ if (!targetId || !session || !enabled) return;
75
+ void tick();
76
+ const id = window.setInterval(() => void tick(), POLL_MS);
77
+ return () => window.clearInterval(id);
78
+ }, [targetId, session, enabled, tick]);
79
+
80
+ if (!selection) {
81
+ return (
82
+ <div className="explorer">
83
+ <div className="explorer-empty">{t.selectSessionForFiles}</div>
84
+ </div>
85
+ );
86
+ }
87
+
88
+ if (!enabled) {
89
+ return (
90
+ <div className="explorer">
91
+ <div className="explorer-empty">{t.fileBrowserUnavailable}</div>
92
+ </div>
93
+ );
94
+ }
95
+
96
+ const navigate = (entry: FileEntry) => {
97
+ if (!path) return;
98
+ const full = joinPath(path, entry.name);
99
+ if (entry.type === 'dir') {
100
+ setManualPath(full);
101
+ } else if (isTextFile(entry.name)) {
102
+ onOpenFile({ targetId: selection.targetId, session: selection.session, path: full, name: entry.name });
103
+ }
104
+ };
105
+
106
+ const atRoot = samePath(path, cwd);
107
+
108
+ return (
109
+ <div className="explorer">
110
+ <div className="explorer-head">
111
+ <span className="explorer-path" title={path ?? ''}>
112
+ {path ? basename(path) : '…'}
113
+ </span>
114
+ <span className="explorer-actions">
115
+ {!following && (
116
+ <button title={t.backToSessionDirectory} onClick={() => setManualPath(null)}>
117
+
118
+ </button>
119
+ )}
120
+ <button title={t.upOneLevel} disabled={!path || path === '/' || atRoot} onClick={() => path && setManualPath(parentPath(path))}>
121
+
122
+ </button>
123
+ </span>
124
+ </div>
125
+ {error && (
126
+ <div className="error-line">
127
+ <span>{error}</span>
128
+ {pauseOnError && (
129
+ <button onClick={manualReconnect}>
130
+ {t.reconnect}
131
+ </button>
132
+ )}
133
+ </div>
134
+ )}
135
+ <div className="explorer-list">
136
+ {entries.map((e) => {
137
+ const openable = e.type === 'dir' || isTextFile(e.name);
138
+ const isOpen =
139
+ openFile?.targetId === selection.targetId && path && openFile.path === joinPath(path, e.name);
140
+ return (
141
+ <div
142
+ key={e.name}
143
+ className={`file-row ${e.type} ${openable ? '' : 'disabled'} ${isOpen ? 'open' : ''}`}
144
+ onClick={() => openable && navigate(e)}
145
+ title={e.name}
146
+ >
147
+ <span className="file-icon">{e.type === 'dir' ? '📁' : isTextFile(e.name) ? '📄' : '▫'}</span>
148
+ <span className="file-name">{e.name}</span>
149
+ </div>
150
+ );
151
+ })}
152
+ {entries.length === 0 && !error && <div className="explorer-empty">{t.emptyDirectory}</div>}
153
+ </div>
154
+ </div>
155
+ );
156
+ }