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
@@ -0,0 +1,96 @@
1
+ import { useCallback, useRef, useState } from 'react';
2
+ import type { OpenFile, Selection, Target } from '../types';
3
+ import { useSettings } from '../settings';
4
+ import { useI18n } from '../i18n';
5
+ import { TargetGroup } from './TargetGroup';
6
+ import { FileExplorer } from './FileExplorer';
7
+ import { SettingsButton } from './SettingsButton';
8
+
9
+ interface Props {
10
+ targets: Target[];
11
+ selection: Selection | null;
12
+ nowMs: number;
13
+ loadError: string | null;
14
+ openFile: OpenFile | null;
15
+ select: (sel: Selection | null) => void;
16
+ onRefreshTargets: () => void;
17
+ onOpenFile: (file: OpenFile) => void;
18
+ }
19
+
20
+ const MIN_BOTTOM = 120;
21
+
22
+ export function Sidebar({
23
+ targets,
24
+ selection,
25
+ nowMs,
26
+ loadError,
27
+ openFile,
28
+ select,
29
+ onRefreshTargets,
30
+ onOpenFile,
31
+ }: Props) {
32
+ const { settings } = useSettings();
33
+ const { t } = useI18n();
34
+ const [bottomHeight, setBottomHeight] = useState(260);
35
+ const dragState = useRef<{ startY: number; startH: number } | null>(null);
36
+
37
+ const onDividerDown = useCallback(
38
+ (e: React.MouseEvent) => {
39
+ e.preventDefault();
40
+ dragState.current = { startY: e.clientY, startH: bottomHeight };
41
+ const onMove = (ev: MouseEvent) => {
42
+ if (!dragState.current) return;
43
+ const delta = dragState.current.startY - ev.clientY;
44
+ const max = window.innerHeight - 220;
45
+ setBottomHeight(Math.max(MIN_BOTTOM, Math.min(max, dragState.current.startH + delta)));
46
+ };
47
+ const onUp = () => {
48
+ dragState.current = null;
49
+ window.removeEventListener('mousemove', onMove);
50
+ window.removeEventListener('mouseup', onUp);
51
+ };
52
+ window.addEventListener('mousemove', onMove);
53
+ window.addEventListener('mouseup', onUp);
54
+ },
55
+ [bottomHeight],
56
+ );
57
+
58
+ const selectedTarget = selection ? targets.find((t) => t.id === selection.targetId) : undefined;
59
+ const fileBrowsingEnabled = !selectedTarget || selectedTarget.kind !== 'winlocal';
60
+
61
+ return (
62
+ <div className="sidebar" style={{ fontSize: settings.sidebarFontSize }}>
63
+ <div className="sidebar-header">
64
+ <h1>tmuxes</h1>
65
+ <button onClick={onRefreshTargets} title={t.reloadTargets}>
66
+
67
+ </button>
68
+ </div>
69
+
70
+ <div className="sidebar-top">
71
+ {loadError && <div className="error-line">{loadError}</div>}
72
+ {targets.map((t) => (
73
+ <TargetGroup key={t.id} target={t} selection={selection} nowMs={nowMs} select={select} />
74
+ ))}
75
+ {!loadError && targets.length === 0 && <div className="empty">{t.noTargets}</div>}
76
+ </div>
77
+
78
+ <div className="sidebar-vdivider" onMouseDown={onDividerDown} title={t.dragResize} />
79
+
80
+ <div className="sidebar-bottom" style={{ height: bottomHeight }}>
81
+ <div className="section-label">{t.workingDirectory}</div>
82
+ <FileExplorer
83
+ selection={selection}
84
+ openFile={openFile}
85
+ enabled={fileBrowsingEnabled}
86
+ pauseOnError={selectedTarget?.kind === 'ssh'}
87
+ onOpenFile={onOpenFile}
88
+ />
89
+ </div>
90
+
91
+ <div className="sidebar-footer">
92
+ <SettingsButton />
93
+ </div>
94
+ </div>
95
+ );
96
+ }
@@ -0,0 +1,31 @@
1
+ import type { ConnStatus } from '../types';
2
+ import { useI18n } from '../i18n';
3
+
4
+ interface Props {
5
+ status: ConnStatus;
6
+ onReconnect: () => void;
7
+ }
8
+
9
+ /** Overlay shown while connecting or when the link is degraded. Renders
10
+ * nothing once connected so the terminal is unobstructed. */
11
+ export function StatusBanner({ status, onReconnect }: Props) {
12
+ const { t } = useI18n();
13
+ if (status.kind === 'connected') return null;
14
+
15
+ if (status.kind === 'connecting') {
16
+ return (
17
+ <div className="status-banner">
18
+ <span className="spinner" />
19
+ <span className="msg">{t.connecting}</span>
20
+ </div>
21
+ );
22
+ }
23
+
24
+ const isError = status.kind === 'error' || status.kind === 'ssh';
25
+ return (
26
+ <div className={`status-banner ${isError ? 'error' : ''}`}>
27
+ <span className="msg">{status.message}</span>
28
+ <button onClick={onReconnect}>{t.reconnect}</button>
29
+ </div>
30
+ );
31
+ }
@@ -0,0 +1,275 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+ import type { AttentionReason, SessionInfo, Selection, Target } from '../types';
3
+ import { api, ApiError } from '../api';
4
+ import { isValidSessionName } from '../util';
5
+ import { useFolders } from '../folders';
6
+ import { useAttention } from '../attention';
7
+ import { useI18n } from '../i18n';
8
+ import { SessionTree } from './SessionTree';
9
+
10
+ interface Props {
11
+ target: Target;
12
+ selection: Selection | null;
13
+ nowMs: number;
14
+ select: (sel: Selection | null) => void;
15
+ }
16
+
17
+ const POLL_MS = 5000;
18
+
19
+ function isAttentionReason(v: string | undefined): v is AttentionReason {
20
+ return v === 'done' || v === 'decision' || v === 'error';
21
+ }
22
+
23
+ export function TargetGroup({ target, selection, nowMs, select }: Props) {
24
+ // Expand the "local-ish" targets by default (local tmux, WSL, native shells).
25
+ const [expanded, setExpanded] = useState(
26
+ target.kind === 'local' || target.kind === 'wsl' || target.kind === 'winlocal',
27
+ );
28
+ const [sessions, setSessions] = useState<SessionInfo[] | null>(null);
29
+ const [loading, setLoading] = useState(false);
30
+ const [error, setError] = useState<string | null>(null);
31
+
32
+ const [showForm, setShowForm] = useState(false);
33
+ const [newName, setNewName] = useState('');
34
+ const [newCommand, setNewCommand] = useState('');
35
+ const [newShell, setNewShell] = useState(target.shells?.[0]?.id ?? '');
36
+ const [formError, setFormError] = useState<string | null>(null);
37
+ const [creating, setCreating] = useState(false);
38
+
39
+ const folders = useFolders(target.id, expanded, target.kind === 'ssh');
40
+ const attention = useAttention();
41
+ const { t } = useI18n();
42
+
43
+ // Avoid overlapping fetches when a poll and a manual refresh race.
44
+ const inFlight = useRef(false);
45
+ // Avoid repeated SSH login failures that can look like brute-force attempts.
46
+ // Collapse/re-expand the target to retry after fixing keys, VPN, or host access.
47
+ const pollingPaused = useRef(false);
48
+ // Last-seen agent hook event key per session; first sight is a baseline.
49
+ const lastAgentEvent = useRef<Map<string, string>>(new Map());
50
+ // PermissionRequest can be auto-reviewed by the agent and disappear quickly.
51
+ // Only notify if the same waiting/decision event survives one more refresh.
52
+ const decisionCandidates = useRef<Map<string, { key: string; notified: boolean }>>(new Map());
53
+
54
+ const eventKey = (s: SessionInfo): string => {
55
+ if (!s.agentKind || !s.agentState || !s.agentNonce) return '';
56
+ return [
57
+ s.agentKind,
58
+ s.agentState,
59
+ s.attentionReason ?? '',
60
+ s.agentEvent ?? '',
61
+ s.agentNonce,
62
+ ].join(':');
63
+ };
64
+
65
+ const detectAttention = useCallback(
66
+ (sessions: SessionInfo[]) => {
67
+ const present = new Set<string>();
68
+ for (const s of sessions) {
69
+ present.add(s.name);
70
+ const key = eventKey(s);
71
+ const seen = lastAgentEvent.current.has(s.name);
72
+ const prev = lastAgentEvent.current.get(s.name);
73
+ lastAgentEvent.current.set(s.name, key);
74
+
75
+ if (s.agentState === 'running') {
76
+ decisionCandidates.current.delete(s.name);
77
+ attention.clearAlert(target.id, s.name);
78
+ } else if (s.agentState === 'waiting' && s.attentionReason === 'decision' && key) {
79
+ const candidate = decisionCandidates.current.get(s.name);
80
+ if (candidate?.key === key) {
81
+ if (seen && !candidate.notified) {
82
+ attention.fire(target.id, s.name, 'decision');
83
+ decisionCandidates.current.set(s.name, { key, notified: true });
84
+ }
85
+ } else {
86
+ decisionCandidates.current.set(s.name, { key, notified: false });
87
+ }
88
+ } else if (
89
+ seen &&
90
+ key &&
91
+ key !== prev &&
92
+ isAttentionReason(s.attentionReason)
93
+ ) {
94
+ decisionCandidates.current.delete(s.name);
95
+ attention.fire(target.id, s.name, s.attentionReason);
96
+ } else {
97
+ decisionCandidates.current.delete(s.name);
98
+ }
99
+ }
100
+ // Forget sessions that vanished.
101
+ for (const name of [...lastAgentEvent.current.keys()]) {
102
+ if (!present.has(name)) {
103
+ lastAgentEvent.current.delete(name);
104
+ decisionCandidates.current.delete(name);
105
+ attention.clearAlert(target.id, name);
106
+ }
107
+ }
108
+ },
109
+ [attention, target.id],
110
+ );
111
+
112
+ const refresh = useCallback(async () => {
113
+ if (pollingPaused.current) return;
114
+ if (inFlight.current) return;
115
+ inFlight.current = true;
116
+ setLoading(true);
117
+ try {
118
+ const { sessions } = await api.getSessions(target.id);
119
+ setSessions(sessions);
120
+ detectAttention(sessions);
121
+ setError(null);
122
+ } catch (e) {
123
+ setError(e instanceof ApiError ? e.message : t.failedListSessions);
124
+ if (target.kind === 'ssh') pollingPaused.current = true;
125
+ } finally {
126
+ setLoading(false);
127
+ inFlight.current = false;
128
+ }
129
+ }, [target.id, target.kind, detectAttention, t.failedListSessions]);
130
+
131
+ const manualReconnect = useCallback(() => {
132
+ pollingPaused.current = false;
133
+ setError(null);
134
+ void refresh();
135
+ }, [refresh]);
136
+
137
+ useEffect(() => {
138
+ if (!expanded) return;
139
+ pollingPaused.current = false;
140
+ void refresh();
141
+ const id = window.setInterval(() => void refresh(), POLL_MS);
142
+ return () => window.clearInterval(id);
143
+ }, [expanded, refresh]);
144
+
145
+ const submitCreate = async () => {
146
+ const name = newName.trim();
147
+ if (name && !isValidSessionName(name)) {
148
+ setFormError(t.invalidSessionName);
149
+ return;
150
+ }
151
+ setCreating(true);
152
+ setFormError(null);
153
+ try {
154
+ const { name: created } = await api.createSession(target.id, {
155
+ name: name || undefined,
156
+ command: newCommand.trim() || undefined,
157
+ shell: target.kind === 'winlocal' ? newShell || undefined : undefined,
158
+ });
159
+ setNewName('');
160
+ setNewCommand('');
161
+ setShowForm(false);
162
+ await refresh();
163
+ select({ targetId: target.id, session: created });
164
+ } catch (e) {
165
+ setFormError(e instanceof ApiError ? e.message : t.failedCreateSession);
166
+ } finally {
167
+ setCreating(false);
168
+ }
169
+ };
170
+
171
+ const handleRename = async (oldName: string, next: string) => {
172
+ try {
173
+ await api.renameSession(target.id, oldName, next);
174
+ await refresh();
175
+ if (selection?.targetId === target.id && selection.session === oldName) {
176
+ select({ targetId: target.id, session: next });
177
+ }
178
+ } catch (e) {
179
+ setError(e instanceof ApiError ? e.message : t.renameFailed);
180
+ }
181
+ };
182
+
183
+ const handleKill = async (name: string) => {
184
+ try {
185
+ await api.killSession(target.id, name);
186
+ if (selection?.targetId === target.id && selection.session === name) select(null);
187
+ await refresh();
188
+ } catch (e) {
189
+ setError(e instanceof ApiError ? e.message : t.killFailed);
190
+ }
191
+ };
192
+
193
+ return (
194
+ <div className="target-group">
195
+ <div className="target-head" onClick={() => setExpanded((v) => !v)}>
196
+ <span className="caret">{expanded ? '▾' : '▸'}</span>
197
+ <span className="label">{target.label}</span>
198
+ <span className="kind">{target.kind}</span>
199
+ {expanded && loading && <span className="badge loading">…</span>}
200
+ {expanded && error && (
201
+ <span className="badge err" title={error}>
202
+ !
203
+ </span>
204
+ )}
205
+ </div>
206
+
207
+ {expanded && (
208
+ <div className="session-list">
209
+ {error && (
210
+ <div className="error-line">
211
+ <span>{error}</span>
212
+ {target.kind === 'ssh' && (
213
+ <button onClick={manualReconnect} disabled={loading}>
214
+ {t.reconnect}
215
+ </button>
216
+ )}
217
+ </div>
218
+ )}
219
+
220
+ <SessionTree
221
+ targetId={target.id}
222
+ sessions={sessions ?? []}
223
+ folders={folders}
224
+ selection={selection}
225
+ nowMs={nowMs}
226
+ select={select}
227
+ onRename={(oldName, next) => void handleRename(oldName, next)}
228
+ onKill={(name) => void handleKill(name)}
229
+ />
230
+
231
+ <div className="list-toolbar">
232
+ <button onClick={() => folders.addFolder(null, t.folderDefaultName)} title={t.newFolderTitle}>
233
+ {t.newFolder}
234
+ </button>
235
+ <button onClick={() => setShowForm((v) => !v)}>{t.newSession}</button>
236
+ </div>
237
+
238
+ {showForm && (
239
+ <div className="create-form" onClick={(e) => e.stopPropagation()}>
240
+ {target.kind === 'winlocal' && target.shells && target.shells.length > 0 && (
241
+ <select value={newShell} onChange={(e) => setNewShell(e.target.value)}>
242
+ {target.shells.map((s) => (
243
+ <option key={s.id} value={s.id}>
244
+ {s.label}
245
+ </option>
246
+ ))}
247
+ </select>
248
+ )}
249
+ <input
250
+ autoFocus
251
+ placeholder={t.nameOptional}
252
+ value={newName}
253
+ onChange={(e) => setNewName(e.target.value)}
254
+ onKeyDown={(e) => e.key === 'Enter' && void submitCreate()}
255
+ />
256
+ <input
257
+ placeholder={t.commandOptional}
258
+ value={newCommand}
259
+ onChange={(e) => setNewCommand(e.target.value)}
260
+ onKeyDown={(e) => e.key === 'Enter' && void submitCreate()}
261
+ />
262
+ {formError && <div className="field-error">{formError}</div>}
263
+ <div className="row">
264
+ <button className="primary" disabled={creating} onClick={() => void submitCreate()}>
265
+ {t.create}
266
+ </button>
267
+ <button onClick={() => setShowForm(false)}>{t.cancel}</button>
268
+ </div>
269
+ </div>
270
+ )}
271
+ </div>
272
+ )}
273
+ </div>
274
+ );
275
+ }
@@ -0,0 +1,192 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import { createTerminal, type TerminalHandle } from '../hooks/useTerminal';
3
+ import { createTmuxSocket, type TmuxSocket } from '../hooks/useTmuxSocket';
4
+ import { api, ApiError, terminalSocketUrl } from '../api';
5
+ import type { ConnStatus, LaunchAgent, Target } from '../types';
6
+ import { useI18n } from '../i18n';
7
+ import { StatusBanner } from './StatusBanner';
8
+
9
+ interface Props {
10
+ targetId: string;
11
+ targetKind: Target['kind'];
12
+ targetLabel: string;
13
+ session: string;
14
+ fontSize: number;
15
+ }
16
+
17
+ const RESIZE_DEBOUNCE_MS = 80;
18
+
19
+ /** Mounted with key={targetId/session}, so a selection change is a full
20
+ * remount — no stale terminal/socket state. */
21
+ export function TerminalPanel({ targetId, targetKind, targetLabel, session, fontSize }: Props) {
22
+ const { t } = useI18n();
23
+ const tRef = useRef(t);
24
+ const hostRef = useRef<HTMLDivElement>(null);
25
+ const handleRef = useRef<TerminalHandle | null>(null);
26
+ const socketRef = useRef<TmuxSocket | null>(null);
27
+ const [status, setStatus] = useState<ConnStatus>({ kind: 'connecting' });
28
+ const [generation, setGeneration] = useState(0);
29
+ const [startingAgent, setStartingAgent] = useState<LaunchAgent | null>(null);
30
+ const [agentError, setAgentError] = useState<string | null>(null);
31
+ const terminalRetryUsed = useRef(false);
32
+
33
+ useEffect(() => {
34
+ terminalRetryUsed.current = false;
35
+ }, [targetId, session]);
36
+
37
+ useEffect(() => {
38
+ tRef.current = t;
39
+ }, [t]);
40
+
41
+ useEffect(() => {
42
+ const host = hostRef.current;
43
+ if (!host) return;
44
+
45
+ setStatus({ kind: 'connecting' });
46
+
47
+ const handle = createTerminal(host, fontSize);
48
+ handleRef.current = handle;
49
+ const initial = handle.refit() ?? { cols: 80, rows: 24 };
50
+ let lastCols = initial.cols;
51
+ let lastRows = initial.rows;
52
+ let exited = false;
53
+
54
+ const retryOnce = (): boolean => {
55
+ if (targetKind !== 'ssh' || terminalRetryUsed.current) return false;
56
+ terminalRetryUsed.current = true;
57
+ setStatus({ kind: 'connecting' });
58
+ setGeneration((g) => g + 1);
59
+ return true;
60
+ };
61
+
62
+ const socket = createTmuxSocket(
63
+ terminalSocketUrl(targetId, session, initial.cols, initial.rows),
64
+ {
65
+ onOpen: () => {
66
+ /* wait for bytes before declaring connected */
67
+ },
68
+ onOutput: (bytes) => {
69
+ handle.term.write(bytes);
70
+ setStatus((s) => (s.kind === 'connecting' || s.kind === 'ssh' ? { kind: 'connected' } : s));
71
+ },
72
+ onControl: (msg) => {
73
+ if (msg.type === 'ready') {
74
+ // Re-assert geometry now that the PTY exists (matters for ssh).
75
+ socket.resize(handle.term.cols, handle.term.rows);
76
+ } else if (msg.type === 'ssh') {
77
+ setStatus((s) => (s.kind === 'connecting' ? { kind: 'ssh', message: msg.message } : s));
78
+ } else if (msg.type === 'error') {
79
+ setStatus({ kind: 'error', message: msg.message });
80
+ } else if (msg.type === 'exit') {
81
+ exited = true;
82
+ if (msg.code !== 0 && retryOnce()) return;
83
+ setStatus({
84
+ kind: 'disconnected',
85
+ message:
86
+ msg.code === 0 || msg.code === null
87
+ ? tRef.current.sessionEnded
88
+ : tRef.current.sessionEndedExit(msg.code),
89
+ });
90
+ }
91
+ },
92
+ onClose: () => {
93
+ if (!exited) {
94
+ if (retryOnce()) return;
95
+ setStatus({
96
+ kind: targetKind === 'ssh' ? 'ssh' : 'disconnected',
97
+ message:
98
+ targetKind === 'ssh'
99
+ ? tRef.current.sshInterrupted
100
+ : tRef.current.disconnected,
101
+ });
102
+ }
103
+ },
104
+ },
105
+ );
106
+
107
+ socketRef.current = socket;
108
+ const dataSub = handle.term.onData((d) => socket.sendInput(d));
109
+ handle.term.focus();
110
+
111
+ // Debounced fit on container resize → push new geometry to the PTY.
112
+ let resizeTimer: number | undefined;
113
+ const observer = new ResizeObserver(() => {
114
+ window.clearTimeout(resizeTimer);
115
+ resizeTimer = window.setTimeout(() => {
116
+ const geo = handle.refit();
117
+ if (geo && (geo.cols !== lastCols || geo.rows !== lastRows)) {
118
+ lastCols = geo.cols;
119
+ lastRows = geo.rows;
120
+ socket.resize(geo.cols, geo.rows);
121
+ }
122
+ }, RESIZE_DEBOUNCE_MS);
123
+ });
124
+ observer.observe(host);
125
+
126
+ return () => {
127
+ window.clearTimeout(resizeTimer);
128
+ observer.disconnect();
129
+ dataSub.dispose();
130
+ socket.close();
131
+ handle.dispose();
132
+ handleRef.current = null;
133
+ socketRef.current = null;
134
+ };
135
+ }, [targetId, targetKind, session, generation]);
136
+
137
+ // Live-apply terminal font size without remounting (preserves scrollback).
138
+ useEffect(() => {
139
+ const handle = handleRef.current;
140
+ if (!handle) return;
141
+ if (handle.term.options.fontSize === fontSize) return;
142
+ handle.term.options.fontSize = fontSize;
143
+ const geo = handle.refit();
144
+ if (geo) socketRef.current?.resize(geo.cols, geo.rows);
145
+ }, [fontSize]);
146
+
147
+ const launchAgent = async (agent: LaunchAgent) => {
148
+ setStartingAgent(agent);
149
+ setAgentError(null);
150
+ try {
151
+ await api.launchAgent(targetId, session, agent);
152
+ handleRef.current?.term.focus();
153
+ } catch (e) {
154
+ setAgentError(e instanceof ApiError ? e.message : t.failedLaunchAgent);
155
+ } finally {
156
+ setStartingAgent(null);
157
+ }
158
+ };
159
+
160
+ return (
161
+ <div className="panel">
162
+ {targetKind !== 'winlocal' && (
163
+ <div className="agent-toolbar">
164
+ <button
165
+ disabled={startingAgent !== null}
166
+ onClick={() => void launchAgent('claude')}
167
+ title={t.runClaude}
168
+ >
169
+ claude
170
+ </button>
171
+ <button
172
+ disabled={startingAgent !== null}
173
+ onClick={() => void launchAgent('codex')}
174
+ title={t.runCodex}
175
+ >
176
+ codex
177
+ </button>
178
+ {agentError && <span className="agent-error" title={agentError}>!</span>}
179
+ </div>
180
+ )}
181
+ <div className="term-host" ref={hostRef} />
182
+ <StatusBanner status={status} onReconnect={() => setGeneration((g) => g + 1)} />
183
+ {status.kind !== 'connected' && status.kind !== 'connecting' && (
184
+ <div className="panel-placeholder" style={{ pointerEvents: 'none' }}>
185
+ <div style={{ opacity: 0.4 }}>
186
+ {targetLabel} · {session}
187
+ </div>
188
+ </div>
189
+ )}
190
+ </div>
191
+ );
192
+ }