loadtoagent 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/README.ko.md +175 -0
  2. package/README.md +175 -0
  3. package/README.zh-CN.md +175 -0
  4. package/bin/loadtoagent.js +171 -0
  5. package/docs/ARCHITECTURE.md +30 -0
  6. package/docs/PROVIDER-CONTRACTS.md +58 -0
  7. package/docs/assets/loadtoagent-dashboard.png +0 -0
  8. package/docs/assets/loadtoagent-demo.gif +0 -0
  9. package/main.js +493 -0
  10. package/package.json +133 -0
  11. package/preload.js +75 -0
  12. package/renderer/app-agent-actions.js +285 -0
  13. package/renderer/app-bootstrap.js +95 -0
  14. package/renderer/app-dashboard.js +361 -0
  15. package/renderer/app-drawer-content.js +203 -0
  16. package/renderer/app-drawer-data.js +41 -0
  17. package/renderer/app-drawer.js +183 -0
  18. package/renderer/app-events-dialogs.js +169 -0
  19. package/renderer/app-events-filters.js +74 -0
  20. package/renderer/app-events-navigation.js +96 -0
  21. package/renderer/app-events-sessions.js +195 -0
  22. package/renderer/app-events.js +16 -0
  23. package/renderer/app-graph-layout.js +71 -0
  24. package/renderer/app-graph-model.js +107 -0
  25. package/renderer/app-graph-orchestration.js +76 -0
  26. package/renderer/app-graph-view.js +544 -0
  27. package/renderer/app-run-modal.js +221 -0
  28. package/renderer/app-session-render.js +243 -0
  29. package/renderer/app-tmux-render.js +247 -0
  30. package/renderer/app.js +608 -0
  31. package/renderer/fonts/geist-ext.woff2 +0 -0
  32. package/renderer/fonts/geist.woff2 +0 -0
  33. package/renderer/i18n-messages.js +355 -0
  34. package/renderer/i18n.js +122 -0
  35. package/renderer/index.html +386 -0
  36. package/renderer/shared.js +26 -0
  37. package/renderer/styles-agent-map.css +401 -0
  38. package/renderer/styles-cards.css +600 -0
  39. package/renderer/styles-collaboration.css +534 -0
  40. package/renderer/styles-components.css +1293 -0
  41. package/renderer/styles-onboarding.css +344 -0
  42. package/renderer/styles-overlays.css +284 -0
  43. package/renderer/styles-product.css +686 -0
  44. package/renderer/styles-responsive-product.css +689 -0
  45. package/renderer/styles-responsive-runtime.css +718 -0
  46. package/renderer/styles-responsive-shell.css +533 -0
  47. package/renderer/styles-responsive-workflows.css +778 -0
  48. package/renderer/styles-run-composer.css +695 -0
  49. package/renderer/styles-settings.css +606 -0
  50. package/renderer/styles-terminal.css +1241 -0
  51. package/renderer/styles-tmux.css +1162 -0
  52. package/renderer/styles-workflow-map.css +513 -0
  53. package/renderer/styles-workflows.css +1217 -0
  54. package/renderer/styles.css +486 -0
  55. package/renderer/terminal-agent.js +152 -0
  56. package/renderer/terminal-events.js +226 -0
  57. package/renderer/terminal-workbench.js +464 -0
  58. package/renderer/terminal.js +497 -0
  59. package/src/agentMonitor/claudeParser.js +197 -0
  60. package/src/agentMonitor/codexCollaboration.js +95 -0
  61. package/src/agentMonitor/codexParser.js +447 -0
  62. package/src/agentMonitor/genericParser.js +244 -0
  63. package/src/agentMonitor/hierarchy.js +248 -0
  64. package/src/agentMonitor/sessionFiles.js +86 -0
  65. package/src/agentMonitor.js +591 -0
  66. package/src/agentRunner.js +465 -0
  67. package/src/bridgeServer.js +246 -0
  68. package/src/contracts.js +71 -0
  69. package/src/diagnostics.js +23 -0
  70. package/src/ipc/registerAgentIpc.js +12 -0
  71. package/src/ipc/registerAppIpc.js +20 -0
  72. package/src/ipc/registerTerminalIpc.js +50 -0
  73. package/src/ipc/registerTmuxIpc.js +30 -0
  74. package/src/ipc/registerWorkspaceIpc.js +14 -0
  75. package/src/monitorWorker.js +273 -0
  76. package/src/processMonitor.js +394 -0
  77. package/src/providerRegistry.js +120 -0
  78. package/src/terminalManager.js +438 -0
  79. package/src/tmuxController.js +183 -0
  80. package/src/tmuxMonitor.js +432 -0
  81. package/src/updateManager.js +339 -0
  82. package/src/workspaceStore.js +77 -0
@@ -0,0 +1,464 @@
1
+ 'use strict';
2
+
3
+ /** Own xterm views, terminal/tmux selection, capture, and management actions. */
4
+ window.LoadToAgentTerminalWorkbench = function createModule(context) {
5
+ const {
6
+ $, state, notice, setConnectionState, currentSession, currentTmux, saveCurrentDraft, restoreCurrentDraft,
7
+ renderHistoryPanel, terminalTypeMark, terminalTypeLabel, xtermOptions, preferredWorkspace, firstDistro, guarded,
8
+ esc, errorMessage, modeSessions, STATUS_LABELS, visibleBoundAgent, moveWorkbench, tmuxRows, updateSnapshot,
9
+ } = context;
10
+
11
+ function createXtermHost(key, readOnly = false) {
12
+ if (!window.Terminal || !window.FitAddon || !window.FitAddon.FitAddon) throw new Error('명령창 화면을 불러오지 못했습니다.');
13
+ const host = document.createElement('div');
14
+ host.className = 'terminal-screen hidden';
15
+ host.dataset.terminalScreen = key;
16
+ $('#terminalViewport').appendChild(host);
17
+ const terminal = new window.Terminal(xtermOptions(readOnly));
18
+ const fit = new window.FitAddon.FitAddon();
19
+ terminal.loadAddon(fit);
20
+ terminal.open(host);
21
+ const syncScrollState = viewportY => {
22
+ host.dataset.viewportY = String(Number(viewportY) || 0);
23
+ host.dataset.baseY = String(Number(terminal.buffer.active.baseY) || 0);
24
+ };
25
+ terminal.onScroll(syncScrollState);
26
+ syncScrollState(0);
27
+ const entry = { terminal, fit, host, readOnly };
28
+ if (readOnly) {
29
+ const rememberUserViewport = () => requestAnimationFrame(() => {
30
+ const buffer = terminal.buffer.active;
31
+ state.remoteViewportAnchor = Number(buffer.viewportY) || 0;
32
+ state.remoteViewportAtBottom = state.remoteViewportAnchor >= Number(buffer.baseY || 0);
33
+ });
34
+ host.addEventListener('wheel', rememberUserViewport, { passive: true });
35
+ host.addEventListener('pointerup', rememberUserViewport);
36
+ host.addEventListener('keyup', event => {
37
+ if (['PageUp', 'PageDown', 'Home', 'End', 'ArrowUp', 'ArrowDown'].includes(event.key)) rememberUserViewport();
38
+ });
39
+ }
40
+ if (!readOnly) {
41
+ terminal.onData(data => {
42
+ if (state.selectedId === key) Promise.resolve(window.loadtoagent.terminalWrite(key, data)).catch(error => notice(errorMessage(error), 'error'));
43
+ });
44
+ terminal.onResize(size => {
45
+ Promise.resolve(window.loadtoagent.terminalResize(key, size.cols, size.rows)).catch(error => {
46
+ window.LoadToAgentRendererUtils.reportRecoverableError('terminal-resize', error);
47
+ });
48
+ });
49
+ }
50
+ return entry;
51
+ }
52
+
53
+ function fitEntry(entry, sessionId = '') {
54
+ if (!entry || entry.host.classList.contains('hidden')) return;
55
+ requestAnimationFrame(() => {
56
+ try {
57
+ entry.fit.fit();
58
+ if (sessionId) {
59
+ Promise.resolve(window.loadtoagent.terminalResize(sessionId, entry.terminal.cols, entry.terminal.rows)).catch(error => {
60
+ window.LoadToAgentRendererUtils.reportRecoverableError('terminal-fit-resize', error);
61
+ });
62
+ }
63
+ } catch (error) {
64
+ window.LoadToAgentRendererUtils.reportRecoverableError('terminal-fit', error);
65
+ }
66
+ });
67
+ }
68
+
69
+ async function ensureSessionTerminal(session) {
70
+ let entry = state.terminals.get(session.id);
71
+ if (!entry) {
72
+ entry = createXtermHost(session.id, false);
73
+ state.terminals.set(session.id, entry);
74
+ const detail = await window.loadtoagent.terminalGet(session.id);
75
+ if (detail && detail.replay) entry.terminal.write(detail.replay);
76
+ }
77
+ return entry;
78
+ }
79
+
80
+ function ensureRemoteTerminal() {
81
+ if (!state.remoteTerminal) state.remoteTerminal = createXtermHost('__tmux_remote__', true);
82
+ return state.remoteTerminal;
83
+ }
84
+
85
+ function hideScreens() {
86
+ for (const entry of state.terminals.values()) entry.host.classList.add('hidden');
87
+ if (state.remoteTerminal) state.remoteTerminal.host.classList.add('hidden');
88
+ $('#terminalEmpty').classList.add('hidden');
89
+ }
90
+
91
+ function renderSessions() {
92
+ const general = modeSessions('general');
93
+ const running = general.filter(item => item.status === 'running').length;
94
+ const background = general.filter(item => item.background && item.status === 'running').length;
95
+ $('#navTerminalCount').textContent = running;
96
+ $('#terminalSessionSummary').textContent = [
97
+ window.LoadToAgentI18n.t('common.active', { count: running }),
98
+ background ? window.LoadToAgentI18n.t('session.background_count', { count: background }) : '',
99
+ window.LoadToAgentI18n.t('session.total_count', { count: general.length }),
100
+ ].filter(Boolean).join(' · ');
101
+ $('#terminalSessionList').innerHTML = general.length ? general.map((session, index) => `
102
+ <div class="terminal-session-row">
103
+ <button type="button" draggable="true"
104
+ class="terminal-session-item ${state.selectedId === session.id ? 'active' : ''}"
105
+ data-terminal-id="${esc(session.id)}"
106
+ aria-grabbed="false"
107
+ aria-describedby="terminalReorderHelp"
108
+ title="${esc(window.LoadToAgentI18n.t('terminal.reorder_hint'))}">
109
+ <span class="terminal-session-icon">${esc(terminalTypeMark(session))}</span>
110
+ <span><b>${esc(session.title)}</b><small>${esc(terminalTypeLabel(session))}${session.background ? ' · 백그라운드 유지' : ''} · ${esc(STATUS_LABELS[session.status] || session.status)}</small><em>${esc(session.cwd || session.distro || `PID ${session.pid || '--'}`)}</em><span class="sr-only">${index + 1}/${general.length}</span></span>
111
+ <i class="${session.status === 'running' ? 'live' : ''}"></i>
112
+ </button>
113
+ <span class="terminal-reorder-actions" aria-label="${esc(window.LoadToAgentI18n.t('terminal.reorder_group', { title: session.title }))}">
114
+ <button type="button" data-session-move="-1" data-session-move-id="${esc(session.id)}" aria-label="${esc(window.LoadToAgentI18n.t('terminal.move_up', { title: session.title }))}" ${index === 0 ? 'disabled' : ''}>↑</button>
115
+ <button type="button" data-session-move="1" data-session-move-id="${esc(session.id)}" aria-label="${esc(window.LoadToAgentI18n.t('terminal.move_down', { title: session.title }))}" ${index === general.length - 1 ? 'disabled' : ''}>↓</button>
116
+ </span>
117
+ </div>`).join('') : '<div class="terminal-resource-empty">열어 둔 일반 명령창이 없습니다.</div>';
118
+ }
119
+
120
+ function renderTmuxResources() {
121
+ const distros = state.snapshot && state.snapshot.tmux && state.snapshot.tmux.distros || [];
122
+ if (!distros.length) {
123
+ $('#terminalTmuxList').innerHTML = '<div class="terminal-resource-empty">Linux에서 나눠 실행 중인 작업이 없습니다.</div>';
124
+ return;
125
+ }
126
+ $('#terminalTmuxList').innerHTML = distros.map(distro => `
127
+ <section class="terminal-tmux-group">
128
+ <header><b>${esc(distro.name)}</b><span>작업 묶음 ${(distro.sessions || []).length}개</span></header>
129
+ ${(distro.sessions || []).map(session => `
130
+ <div class="terminal-tmux-session"><strong>${esc(session.name)}</strong><small>${session.attached ? '화면에 연결됨' : '뒤에서 실행 중'}</small></div>
131
+ ${(session.windows || []).flatMap(windowItem => (windowItem.panes || []).map(pane => `
132
+ <button type="button" class="terminal-tmux-pane ${state.selectedTmux && state.selectedTmux.distro.name === distro.name && state.selectedTmux.pane.nativeId === pane.nativeId ? 'active' : ''}" data-tmux-distro="${esc(distro.name)}" data-tmux-pane="${esc(pane.nativeId)}">
133
+ <span><b>${esc(pane.nativeId)} · ${esc(windowItem.index)}:${esc(windowItem.name)}</b><small>${esc(pane.command || 'shell')} · ${esc(pane.cwd || '경로 미보고')}</small></span>
134
+ <i class="${pane.agent ? 'agent' : (pane.active ? 'live' : '')}">${pane.agent ? 'AI' : (pane.active ? 'ON' : '')}</i>
135
+ </button>`)).join('')}`).join('')}
136
+ </section>`).join('');
137
+ }
138
+
139
+ function renderTarget() {
140
+ const session = currentSession();
141
+ const remote = currentTmux();
142
+ const bound = visibleBoundAgent();
143
+ const hasTarget = Boolean(session || remote);
144
+ const canInput = Boolean((remote && !remote.pane.dead) || (session && session.status === 'running'));
145
+ const closeButton = $('#terminalCloseBtn');
146
+ closeButton.disabled = !hasTarget;
147
+ closeButton.textContent = remote && !session ? '선택 해제' : window.LoadToAgentI18n.t("ui.end_session");
148
+ closeButton.classList.toggle('terminal-danger-button', Boolean(session));
149
+ $('#terminalRestartBtn').classList.toggle('hidden', !session || session.status === 'running' || session.type === 'agent');
150
+ $('#terminalRestartBtn').disabled = !session || session.status === 'running';
151
+ $('#terminalCommandInput').disabled = !canInput;
152
+ const commandForm = $('#terminalCommandForm');
153
+ const commandButton = commandForm.querySelector('button');
154
+ commandButton.disabled = !canInput || state.commandSending;
155
+ commandForm.toggleAttribute('aria-busy', state.commandSending);
156
+ const commandButtonLabel = commandButton.querySelector('span');
157
+ if (commandButtonLabel) commandButtonLabel.textContent = state.commandSending ? '보내는 중' : window.LoadToAgentI18n.t("common.send");
158
+ document.querySelectorAll('[data-terminal-signal]').forEach(button => { button.disabled = !canInput; });
159
+ $('#terminalAttachBtn').classList.toggle('hidden', !remote || Boolean(session));
160
+ $('#terminalTmuxTools').classList.toggle('hidden', !remote || Boolean(session));
161
+ if (session) {
162
+ setConnectionState(STATUS_LABELS[session.status] || session.status, session.status);
163
+ $('#terminalTargetIcon').textContent = terminalTypeMark(session);
164
+ $('#terminalTargetMeta').innerHTML = `<b>${esc(session.title)}</b><span>${bound ? '● 기존 AI 세션 유지 중 · ' : ''}${esc(session.type.toUpperCase())} · PID ${session.pid || '--'} · ${esc(session.cwd || session.distro || '')}</span>`;
165
+ $('#terminalConsoleCaption').textContent = `${terminalTypeLabel(session)} · PID ${session.pid || '--'}`;
166
+ $('#terminalConsoleState').textContent = canInput ? window.LoadToAgentI18n.t("ui.direct_input_available") : window.LoadToAgentI18n.t("ui.ended_session");
167
+ $('#terminalConsoleState').dataset.status = session.status;
168
+ } else if (remote) {
169
+ setConnectionState(remote.pane.dead ? '종료된 tmux 칸' : 'tmux 연결됨', remote.pane.dead ? 'exited' : 'running');
170
+ $('#terminalTargetIcon').textContent = 'tm';
171
+ $('#terminalTargetMeta').innerHTML = `<b>${esc(remote.distro.name)} · ${esc(remote.session.name)} · ${esc(remote.pane.nativeId)}</b><span>${esc(remote.window.index)}:${esc(remote.window.name)} · ${esc(remote.pane.command || 'shell')} · ${esc(remote.pane.cwd || '')}</span>`;
172
+ $('#terminalConsoleCaption').textContent = `${remote.window.index}:${remote.window.name} · ${remote.pane.command || 'shell'}`;
173
+ $('#terminalConsoleState').textContent = remote.pane.dead ? window.LoadToAgentI18n.t("ui.ended_pane") : window.LoadToAgentI18n.t("ui.ready_for_commands");
174
+ $('#terminalConsoleState').dataset.status = remote.pane.dead ? 'exited' : 'running';
175
+ } else {
176
+ setConnectionState(window.LoadToAgentI18n.t("ui.waiting_for_selection"));
177
+ $('#terminalTargetIcon').textContent = '›_';
178
+ $('#terminalTargetMeta').innerHTML = state.mode === 'tmux'
179
+ ? '<b>아직 선택한 tmux 명령창이 없습니다</b><span>왼쪽 tmux 목록이나 위 지도에서 조작할 칸을 선택하세요.</span>'
180
+ : `<b>${window.LoadToAgentI18n.t("ui.select_a_session")}</b><span>${window.LoadToAgentI18n.t("ui.choose_a_session_on_the_left_or_create_a_new")}</span>`;
181
+ $('#terminalConsoleCaption').textContent = window.LoadToAgentI18n.t("ui.select_a_session_to_show_its_output_here");
182
+ $('#terminalConsoleState').textContent = window.LoadToAgentI18n.t("ui.waiting_for_selection");
183
+ $('#terminalConsoleState').dataset.status = '';
184
+ }
185
+ const commandLabel = $('#terminalCommandLabel');
186
+ const commandInput = $('#terminalCommandInput');
187
+ if (commandLabel) commandLabel.textContent = bound ? window.LoadToAgentI18n.t("ui.continue_instructing_ai") : (remote ? window.LoadToAgentI18n.t("ui.send_to_tmux_terminal") : window.LoadToAgentI18n.t("ui.send_command_to_terminal"));
188
+ if (commandInput) commandInput.placeholder = !hasTarget
189
+ ? window.LoadToAgentI18n.t("ui.select_a_session_on_the_left_first")
190
+ : (bound ? '이전 대화에 이어서 AI에게 지시할 내용을 입력하세요' : window.LoadToAgentI18n.t("ui.enter_a_command_to_run"));
191
+ renderHistoryPanel();
192
+ }
193
+
194
+ async function showSelection() {
195
+ hideScreens();
196
+ const session = currentSession();
197
+ const remote = currentTmux();
198
+ if (session) {
199
+ const entry = await ensureSessionTerminal(session);
200
+ entry.host.classList.remove('hidden');
201
+ fitEntry(entry, session.id);
202
+ stopCapture();
203
+ } else if (remote) {
204
+ const entry = ensureRemoteTerminal();
205
+ entry.host.classList.remove('hidden');
206
+ fitEntry(entry);
207
+ startCapture();
208
+ } else {
209
+ $('#terminalEmpty').classList.remove('hidden');
210
+ stopCapture();
211
+ }
212
+ renderTarget();
213
+ }
214
+
215
+ async function selectSession(id) {
216
+ saveCurrentDraft();
217
+ state.captureGeneration += 1;
218
+ state.selectedId = id;
219
+ state.selectedTmux = null;
220
+ renderSessions();
221
+ renderTmuxResources();
222
+ await showSelection();
223
+ restoreCurrentDraft();
224
+ }
225
+
226
+ async function selectTmux(distroName, paneId) {
227
+ const row = tmuxRows().find(item => item.distro.name === distroName && item.pane.nativeId === paneId);
228
+ if (!row) return notice('선택한 나눠진 명령창을 찾을 수 없습니다.', 'error');
229
+ saveCurrentDraft();
230
+ state.captureGeneration += 1;
231
+ state.selectedId = null;
232
+ state.selectedTmux = row;
233
+ state.remoteCapture = '';
234
+ state.remoteViewportAnchor = null;
235
+ state.remoteViewportAtBottom = false;
236
+ if (state.remoteTerminal) state.remoteTerminal.terminal.clear();
237
+ renderSessions();
238
+ renderTmuxResources();
239
+ await showSelection();
240
+ restoreCurrentDraft();
241
+ }
242
+
243
+ async function selectTmuxById(paneId) {
244
+ const row = tmuxRows().find(item => item.pane.id === paneId || item.pane.nativeId === paneId);
245
+ if (!row) return notice('선택한 tmux 명령창을 찾을 수 없습니다.', 'error');
246
+ state.mode = 'tmux';
247
+ moveWorkbench('tmux');
248
+ return selectTmux(row.distro.name, row.pane.nativeId);
249
+ }
250
+
251
+ function renderAll() {
252
+ renderSessions();
253
+ renderTmuxResources();
254
+ renderTarget();
255
+ }
256
+
257
+ async function refreshSessions(payload = null) {
258
+ const nextSessions = payload && Array.isArray(payload.sessions) ? payload.sessions : await window.loadtoagent.terminalList();
259
+ state.sessions = Array.isArray(nextSessions) ? nextSessions : [];
260
+ const activeIds = new Set(state.sessions.map(session => session.id));
261
+ for (const [id, entry] of state.terminals) {
262
+ if (activeIds.has(id)) continue;
263
+ entry.terminal.dispose();
264
+ entry.host.remove();
265
+ state.terminals.delete(id);
266
+ state.commandDrafts.delete(id);
267
+ }
268
+ if (state.selectedId && !state.sessions.some(item => item.id === state.selectedId)) state.selectedId = null;
269
+ renderAll();
270
+ if (state.active) await showSelection();
271
+ }
272
+
273
+ async function createTerminal(type) {
274
+ const distro = type === 'wsl' ? firstDistro() : null;
275
+ if (type === 'wsl' && !distro) return notice('사용 가능한 Linux 환경이 없습니다.', 'error');
276
+ const created = await guarded(() => window.loadtoagent.terminalCreate({
277
+ type,
278
+ cwd: (type === 'powershell' || type === 'shell') ? (preferredWorkspace() || undefined) : undefined,
279
+ distro: distro && distro.name,
280
+ title: type === 'powershell' ? 'PowerShell' : (type === 'shell' ? state.platform.localShellLabel : `${distro.name} 셸`),
281
+ cols: 120,
282
+ rows: 32,
283
+ }), `${type === 'powershell' ? 'Windows' : (type === 'shell' ? state.platform.label : 'Linux')} 명령창을 열었습니다.`);
284
+ if (!created) return;
285
+ await refreshSessions();
286
+ await selectSession(created.id);
287
+ }
288
+
289
+ async function captureRemote() {
290
+ if (state.captureInFlight) return;
291
+ const remote = currentTmux();
292
+ if (!remote || !state.active || state.selectedId) return;
293
+ const captureKey = `${remote.distro.name}:${remote.pane.nativeId}`;
294
+ const captureGeneration = state.captureGeneration;
295
+ state.captureInFlight = true;
296
+ try {
297
+ const result = await guarded(() => window.loadtoagent.tmuxCapture({ distro: remote.distro.name, target: remote.pane.nativeId, lines: 1_500 }));
298
+ const current = currentTmux();
299
+ if (!current || `${current.distro.name}:${current.pane.nativeId}` !== captureKey) return;
300
+ if (!result || typeof result.output !== 'string' || result.output === state.remoteCapture) return;
301
+ const firstCapture = !state.remoteCapture;
302
+ state.remoteCapture = result.output;
303
+ const entry = ensureRemoteTerminal();
304
+ const buffer = entry.terminal.buffer.active;
305
+ const previousViewport = state.remoteViewportAnchor == null
306
+ ? Number(buffer && buffer.viewportY || 0)
307
+ : state.remoteViewportAnchor;
308
+ const wasAtBottom = state.remoteViewportAnchor == null
309
+ ? Boolean(buffer && buffer.viewportY >= buffer.baseY)
310
+ : state.remoteViewportAtBottom;
311
+ entry.terminal.reset();
312
+ await new Promise(resolve => entry.terminal.write(result.output.replace(/\n/g, '\r\n'), resolve));
313
+ const selected = currentTmux();
314
+ if (!state.active || captureGeneration !== state.captureGeneration || !selected || `${selected.distro.name}:${selected.pane.nativeId}` !== captureKey) {
315
+ entry.terminal.reset();
316
+ state.remoteCapture = '';
317
+ setTimeout(captureRemote, 0);
318
+ return;
319
+ }
320
+ requestAnimationFrame(() => {
321
+ const latest = currentTmux();
322
+ if (captureGeneration !== state.captureGeneration || !latest || `${latest.distro.name}:${latest.pane.nativeId}` !== captureKey) return;
323
+ if (firstCapture) {
324
+ entry.terminal.scrollToTop();
325
+ state.remoteViewportAnchor = 0;
326
+ state.remoteViewportAtBottom = false;
327
+ } else if (state.remoteViewportAnchor == null ? wasAtBottom : state.remoteViewportAtBottom) entry.terminal.scrollToBottom();
328
+ else entry.terminal.scrollToLine(state.remoteViewportAnchor == null ? previousViewport : state.remoteViewportAnchor);
329
+ state.captureRevision += 1;
330
+ entry.host.dataset.captureRevision = String(state.captureRevision);
331
+ });
332
+ } finally {
333
+ state.captureInFlight = false;
334
+ }
335
+ }
336
+
337
+ function startCapture() {
338
+ stopCapture();
339
+ captureRemote();
340
+ state.captureTimer = setInterval(captureRemote, 1_000);
341
+ }
342
+
343
+ function stopCapture() {
344
+ if (state.captureTimer) clearInterval(state.captureTimer);
345
+ state.captureTimer = null;
346
+ }
347
+
348
+ async function sendCommand(command) {
349
+ const text = String(command || '');
350
+ if (state.commandSending) return false;
351
+ if (!text.trim()) {
352
+ notice('보낼 명령을 입력하세요.', 'error');
353
+ return false;
354
+ }
355
+ const session = currentSession();
356
+ const remote = currentTmux();
357
+ if (!session && !remote) {
358
+ notice('사용할 명령창을 먼저 선택하세요.', 'error');
359
+ return false;
360
+ }
361
+ state.commandSending = true;
362
+ renderTarget();
363
+ try {
364
+ const result = session
365
+ ? await guarded(() => window.loadtoagent.terminalCommand(session.id, text), '명령을 전송했습니다.')
366
+ : await guarded(() => window.loadtoagent.tmuxSendText({ distro: remote.distro.name, target: remote.pane.nativeId, text, enter: true }), '선택한 나눠진 명령창에서 실행했습니다.');
367
+ if (result && remote) setTimeout(captureRemote, 160);
368
+ return Boolean(result);
369
+ } finally {
370
+ state.commandSending = false;
371
+ renderTarget();
372
+ }
373
+ }
374
+
375
+ async function sendSignal(signal) {
376
+ const session = currentSession();
377
+ const remote = currentTmux();
378
+ if (session) return guarded(() => window.loadtoagent.terminalSignal(session.id, signal), signal === 'interrupt' ? 'Ctrl+C를 보냈습니다.' : '화면을 정리했습니다.');
379
+ if (remote) {
380
+ const key = signal === 'interrupt' ? 'C-c' : 'C-l';
381
+ return guarded(() => window.loadtoagent.tmuxSendKey({ distro: remote.distro.name, target: remote.pane.nativeId, key }), `${key}를 보냈습니다.`);
382
+ }
383
+ notice('사용할 명령창을 먼저 선택하세요.', 'error');
384
+ }
385
+
386
+ function openTmuxModal() {
387
+ window.LoadToAgentA11y?.rememberDialogTrigger();
388
+ const distros = state.snapshot && state.snapshot.tmux && state.snapshot.tmux.distros || [];
389
+ $('#tmuxCreateDistro').innerHTML = distros.map(item => `<option value="${esc(item.name)}">${esc(item.name)}</option>`).join('');
390
+ $('#tmuxCreateError').classList.add('hidden');
391
+ $('#tmuxCreateModal').classList.remove('hidden');
392
+ $('#tmuxCreateName').focus();
393
+ }
394
+
395
+ function closeTmuxModal() {
396
+ $('#tmuxCreateModal').classList.add('hidden');
397
+ $('#tmuxCreateForm').reset();
398
+ window.LoadToAgentA11y?.restoreDialogTrigger();
399
+ }
400
+
401
+ async function refreshSnapshot() {
402
+ const snapshot = await guarded(() => window.loadtoagent.snapshot(), '여러 창 작업을 새로고침했습니다.');
403
+ if (snapshot) updateSnapshot(snapshot, state.workspaces);
404
+ }
405
+
406
+ async function attachTmux() {
407
+ const remote = currentTmux();
408
+ if (!remote) return;
409
+ const created = await guarded(() => window.loadtoagent.terminalCreate({
410
+ type: 'tmux',
411
+ distro: remote.distro.name,
412
+ tmuxSession: remote.session.name,
413
+ tmuxPane: remote.pane.nativeId,
414
+ title: `tmux · ${remote.session.name} · ${remote.pane.nativeId}`,
415
+ cols: 120,
416
+ rows: 32,
417
+ }), '키보드로 직접 조작할 수 있게 연결했습니다.');
418
+ if (!created) return;
419
+ await refreshSessions();
420
+ await selectSession(created.id);
421
+ }
422
+
423
+ async function manageTmux(action) {
424
+ const remote = currentTmux();
425
+ if (!remote) return;
426
+ const base = { distro: remote.distro.name };
427
+ let operation = null;
428
+ let message = '';
429
+ if (action === 'rename-session') {
430
+ const name = window.prompt('새 작업 묶음 이름', remote.session.name);
431
+ if (!name || name === remote.session.name) return;
432
+ operation = () => window.loadtoagent.tmuxRenameSession({ ...base, target: remote.session.nativeId, name });
433
+ message = '작업 묶음 이름을 변경했습니다.';
434
+ } else if (action === 'new-window') {
435
+ const name = window.prompt('새 창 이름', 'window');
436
+ if (!name) return;
437
+ operation = () => window.loadtoagent.tmuxNewWindow({ ...base, target: remote.session.nativeId, name, cwd: remote.pane.cwd });
438
+ message = '새 창을 만들었습니다.';
439
+ } else if (action === 'split-horizontal' || action === 'split-vertical') {
440
+ operation = () => window.loadtoagent.tmuxSplitPane({ ...base, target: remote.pane.nativeId, direction: action === 'split-horizontal' ? 'horizontal' : 'vertical', cwd: remote.pane.cwd });
441
+ message = '선택한 명령창을 나눴습니다.';
442
+ } else if (action === 'kill-pane') {
443
+ if (!window.confirm(`${remote.pane.nativeId} 명령창을 닫을까요?`)) return;
444
+ operation = () => window.loadtoagent.tmuxKillPane({ ...base, target: remote.pane.nativeId });
445
+ message = '선택한 명령창을 닫았습니다.';
446
+ } else if (action === 'kill-window') {
447
+ if (!window.confirm(`${remote.window.index}:${remote.window.name} 창과 안에 있는 명령창을 모두 닫을까요?`)) return;
448
+ operation = () => window.loadtoagent.tmuxKillWindow({ ...base, target: remote.window.nativeId });
449
+ message = '창 전체를 닫았습니다.';
450
+ } else if (action === 'kill-session') {
451
+ if (!window.confirm(`${remote.session.name} 작업 묶음을 모두 끝낼까요?`)) return;
452
+ operation = () => window.loadtoagent.tmuxKillSession({ ...base, target: remote.session.nativeId });
453
+ message = '작업 묶음을 끝냈습니다.';
454
+ }
455
+ if (!operation) return;
456
+ const result = await guarded(operation, message);
457
+ if (result) {
458
+ if (action.startsWith('kill-')) state.selectedTmux = null;
459
+ setTimeout(refreshSnapshot, 300);
460
+ }
461
+ }
462
+
463
+ return { createXtermHost, fitEntry, ensureSessionTerminal, ensureRemoteTerminal, hideScreens, renderSessions, renderTmuxResources, renderTarget, showSelection, selectSession, selectTmux, selectTmuxById, renderAll, refreshSessions, createTerminal, captureRemote, startCapture, stopCapture, sendCommand, sendSignal, openTmuxModal, closeTmuxModal, refreshSnapshot, attachTmux, manageTmux };
464
+ };