loadtoagent 1.0.0 → 1.3.1

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 (86) hide show
  1. package/README.ko.md +7 -0
  2. package/README.md +7 -0
  3. package/README.zh-CN.md +7 -0
  4. package/docs/PROVIDER-CONTRACTS.md +23 -1
  5. package/docs/UI-AUDIT-100.md +118 -0
  6. package/docs/UI-AUDIT-101-200.md +120 -0
  7. package/docs/UI-AUDIT-201-300.md +120 -0
  8. package/main.js +183 -37
  9. package/package.json +15 -4
  10. package/preload.js +11 -0
  11. package/renderer/app-agent-actions.js +125 -53
  12. package/renderer/app-bootstrap.js +54 -9
  13. package/renderer/app-dashboard.js +197 -58
  14. package/renderer/app-drawer-content.js +103 -86
  15. package/renderer/app-drawer-data.js +26 -13
  16. package/renderer/app-drawer.js +79 -55
  17. package/renderer/app-events-dialogs.js +146 -37
  18. package/renderer/app-events-filters.js +172 -9
  19. package/renderer/app-events-navigation.js +78 -31
  20. package/renderer/app-events-sessions.js +171 -4
  21. package/renderer/app-events.js +2 -1
  22. package/renderer/app-graph-model.js +32 -4
  23. package/renderer/app-graph-orchestration.js +16 -13
  24. package/renderer/app-graph-view.js +228 -112
  25. package/renderer/app-management.js +211 -0
  26. package/renderer/app-provider-visibility.js +124 -0
  27. package/renderer/app-quality.js +568 -0
  28. package/renderer/app-run-modal.js +103 -33
  29. package/renderer/app-runtime-overview.js +312 -0
  30. package/renderer/app-session-render.js +94 -37
  31. package/renderer/app-tmux-render.js +119 -38
  32. package/renderer/app.js +155 -76
  33. package/renderer/i18n-messages.js +833 -15
  34. package/renderer/i18n.js +104 -0
  35. package/renderer/index.html +162 -75
  36. package/renderer/shared.js +17 -0
  37. package/renderer/styles-agent-map.css +50 -1
  38. package/renderer/styles-cards.css +26 -0
  39. package/renderer/styles-collaboration.css +18 -88
  40. package/renderer/styles-components.css +126 -14
  41. package/renderer/styles-management.css +355 -0
  42. package/renderer/styles-overlays.css +13 -2
  43. package/renderer/styles-product.css +24 -16
  44. package/renderer/styles-quality.css +312 -0
  45. package/renderer/styles-readability.css +862 -0
  46. package/renderer/styles-responsive-product.css +84 -12
  47. package/renderer/styles-responsive-runtime.css +31 -14
  48. package/renderer/styles-responsive-shell.css +44 -48
  49. package/renderer/styles-responsive-workflows.css +37 -17
  50. package/renderer/styles-run-composer.css +5 -0
  51. package/renderer/styles-runtime-overview.css +285 -0
  52. package/renderer/styles-settings.css +160 -0
  53. package/renderer/styles-terminal.css +339 -2
  54. package/renderer/styles-tmux.css +154 -0
  55. package/renderer/styles-workflow-map.css +362 -15
  56. package/renderer/styles-workflows.css +69 -2
  57. package/renderer/styles.css +27 -12
  58. package/renderer/terminal-agent.js +17 -13
  59. package/renderer/terminal-events.js +233 -40
  60. package/renderer/terminal-workbench.js +176 -78
  61. package/renderer/terminal.js +350 -37
  62. package/src/agentMonitor/claudeParser.js +96 -7
  63. package/src/agentMonitor/codexParser.js +59 -4
  64. package/src/agentMonitor/executionActivity.js +282 -0
  65. package/src/agentMonitor/genericParser.js +56 -13
  66. package/src/agentMonitor/hierarchy.js +17 -0
  67. package/src/agentMonitor/responseIntent.js +51 -0
  68. package/src/agentMonitor.js +21 -3
  69. package/src/agentRunner.js +66 -1
  70. package/src/attentionNotifier.js +71 -0
  71. package/src/automationMonitor.js +258 -0
  72. package/src/contracts.js +10 -1
  73. package/src/ipc/registerAgentIpc.js +9 -3
  74. package/src/ipc/registerAppIpc.js +4 -1
  75. package/src/ipc/registerTerminalIpc.js +12 -6
  76. package/src/macUpdateHelper.js +210 -0
  77. package/src/monitorWorker.js +65 -6
  78. package/src/platformPath.js +80 -0
  79. package/src/processMonitor.js +81 -23
  80. package/src/providerVisibilityStore.js +45 -0
  81. package/src/sessionIntelligence.js +247 -0
  82. package/src/terminalHost.js +381 -0
  83. package/src/terminalHostDaemon.js +60 -0
  84. package/src/terminalManager.js +158 -3
  85. package/src/tmuxMonitor.js +2 -2
  86. package/src/updateInstaller.js +175 -0
@@ -2,6 +2,7 @@
2
2
 
3
3
  /** Connect dashboard agent sessions to live or resumed terminal targets. */
4
4
  window.LoadToAgentTerminalAgentActions = function createModule(context) {
5
+ const t = (key, params) => window.LoadToAgentI18n.t(key, params);
5
6
  const {
6
7
  $, state, init, notice, moveWorkbench, selectTmux, selectSession, bindAgent, queueHistoryRefresh,
7
8
  renderTarget, fitEntry, refreshSessions, resumeSupport, resumeLaunchArgs, preferredWorkspace, providerLabel, esc,
@@ -35,7 +36,7 @@ window.LoadToAgentTerminalAgentActions = function createModule(context) {
35
36
  id: `tmux:${row.distro.name}:${pane.nativeId}`,
36
37
  kind: 'tmux',
37
38
  label: `${row.distro.name} · ${row.session.name} · ${pane.nativeId}`,
38
- detail: `${row.window.index}:${row.window.name} · ${pane.command || 'AI 명령창'}`,
39
+ detail: `${row.window.index}:${row.window.name} · ${pane.command || t('terminal.agent.ai_terminal')}`,
39
40
  distro: row.distro.name,
40
41
  paneId: pane.id,
41
42
  paneNativeId: pane.nativeId,
@@ -60,26 +61,26 @@ window.LoadToAgentTerminalAgentActions = function createModule(context) {
60
61
 
61
62
  function requiredAgentTarget(agentSession, targetId = '') {
62
63
  const targets = agentTargets(agentSession);
63
- if (!targets.length) throw new Error('이 AI가 실행 중인 입력 가능한 터미널을 찾지 못했습니다. 외부 터미널에서 시작한 AI는 LoadToAgent가 직접 입력할 수 없습니다.');
64
+ if (!targets.length) throw new Error(t('terminal.agent.no_input_target'));
64
65
  if (targetId) {
65
66
  const selected = targets.find(target => target.id === targetId);
66
- if (!selected) throw new Error('선택한 터미널 연결이 더 이상 유효하지 않습니다.');
67
+ if (!selected) throw new Error(t('terminal.agent.target_expired'));
67
68
  return selected;
68
69
  }
69
- if (targets.length > 1) throw new Error('지시를 보낼 터미널을 먼저 선택하세요.');
70
+ if (targets.length > 1) throw new Error(t('terminal.agent.select_target'));
70
71
  return targets[0];
71
72
  }
72
73
 
73
74
  async function dispatchAgentCommand(agentSession, command, targetId = '') {
74
75
  await init();
75
76
  const text = String(command || '').trim();
76
- if (!text) throw new Error('AI에게 보낼 지시를 입력하세요.');
77
+ if (!text) throw new Error(t('terminal.agent.command_required'));
77
78
  const target = requiredAgentTarget(agentSession, targetId);
78
79
  const result = target.kind === 'tmux'
79
80
  ? await window.loadtoagent.tmuxSendText({ distro: target.distro, target: target.paneNativeId, text, enter: true })
80
81
  : await window.loadtoagent.terminalCommand(target.terminalId, text);
81
- if (!result || result.ok === false) throw new Error(result && result.error || '터미널에 지시를 보내지 못했습니다.');
82
- notice(`${target.label} AI 지시를 보냈습니다.`, 'success');
82
+ if (!result || result.ok === false) throw new Error(result && result.error || t('terminal.agent.send_failed'));
83
+ notice(t('terminal.agent.command_sent', { target: target.label }), 'success');
83
84
  return { ok: true, target };
84
85
  }
85
86
 
@@ -99,7 +100,7 @@ window.LoadToAgentTerminalAgentActions = function createModule(context) {
99
100
  input.value = String(draft || '');
100
101
  state.commandDrafts.set(target.id, input.value);
101
102
  input.focus({ preventScroll: true });
102
- notice(`${target.label} 기존 세션을 유지하고 있습니다. 이전 대화를 보면서 이어서 입력하세요.`, 'success');
103
+ notice(t('terminal.agent.session_kept', { target: target.label }), 'success');
103
104
  return target;
104
105
  }
105
106
 
@@ -108,9 +109,12 @@ window.LoadToAgentTerminalAgentActions = function createModule(context) {
108
109
  const support = resumeSupport(agentSession);
109
110
  if (!support.supported) throw new Error(support.reason);
110
111
  const cwd = String(agentSession.cwd || preferredWorkspace() || '').trim();
111
- if (!cwd) throw new Error('이 AI가 작업하던 폴더를 찾지 못해 세션을 안전하게 재개할 수 없습니다.');
112
+ if (!cwd) throw new Error(t('terminal.agent.cwd_missing'));
112
113
  const prompt = String(draft || '').trim();
113
- const title = `${providerLabel(agentSession.provider)} · ${agentSession.taskName || agentSession.agentName || '세션'} 이어서 작업`;
114
+ const title = t('terminal.agent.resume_title', {
115
+ provider: providerLabel(agentSession.provider),
116
+ session: agentSession.taskName || agentSession.agentName || t('terminal.type.session'),
117
+ });
114
118
  const created = await window.loadtoagent.terminalCreate({
115
119
  type: 'agent',
116
120
  provider: support.provider,
@@ -121,7 +125,7 @@ window.LoadToAgentTerminalAgentActions = function createModule(context) {
121
125
  cols: 120,
122
126
  rows: 32,
123
127
  });
124
- if (!created || !created.id) throw new Error('AI 세션을 재개할 터미널을 만들지 못했습니다.');
128
+ if (!created || !created.id) throw new Error(t('terminal.agent.resume_terminal_failed'));
125
129
  state.mode = 'general';
126
130
  moveWorkbench('general');
127
131
  await refreshSessions();
@@ -143,8 +147,8 @@ window.LoadToAgentTerminalAgentActions = function createModule(context) {
143
147
  input.focus({ preventScroll: true });
144
148
  }
145
149
  notice(sendDraft && prompt
146
- ? `${providerLabel(agentSession.provider)} 세션 ${support.sessionId.slice(0, 12)}을 이어받아 지시를 보냈습니다.`
147
- : `${providerLabel(agentSession.provider)} 세션 ${support.sessionId.slice(0, 12)} 다시 연결했습니다. 이어서 지시할 수 있습니다.`, 'success');
150
+ ? t('terminal.agent.resumed_and_sent', { provider: providerLabel(agentSession.provider), sessionId: support.sessionId.slice(0, 12) })
151
+ : t('terminal.agent.reconnected', { provider: providerLabel(agentSession.provider), sessionId: support.sessionId.slice(0, 12) }), 'success');
148
152
  return { ...target, promptSent: Boolean(sendDraft && prompt) };
149
153
  }
150
154
 
@@ -2,23 +2,89 @@
2
2
 
3
3
  /** Bind terminal DOM/preload events using dependencies owned by terminal.js. */
4
4
  window.LoadToAgentTerminalEvents = function bindTerminalEvents(context) {
5
+ const t = (key, params) => window.LoadToAgentI18n.t(key, params);
5
6
  const {
6
7
  $, state, createTerminal, openTmuxModal, refreshSnapshot, selectSession, selectTmux,
7
8
  sendCommand, currentTargetId, sendSignal, currentSession, guarded, renderAll, showSelection,
8
9
  refreshSessions, renderHistoryPanel, fitEntry, attachTmux, currentTmux, manageTmux,
9
10
  closeTmuxModal, errorMessage, notice, reorderSession, moveSessionByOffset,
11
+ setTerminalFontSize, toggleTerminalFocusMode,
10
12
  } = context;
11
13
 
14
+ const runBusy = async (button, action) => {
15
+ if (!button || button.dataset.busy === 'true') return null;
16
+ const wasDisabled = button.disabled;
17
+ button.dataset.busy = 'true';
18
+ button.disabled = true;
19
+ button.setAttribute('aria-busy', 'true');
20
+ try {
21
+ return await action();
22
+ } finally {
23
+ if (button.isConnected) {
24
+ delete button.dataset.busy;
25
+ button.disabled = wasDisabled;
26
+ button.removeAttribute('aria-busy');
27
+ }
28
+ }
29
+ };
30
+
31
+ const writeTerminalOutput = (entry, data) => {
32
+ if (!entry || !data) return;
33
+ const buffer = entry.terminal.buffer.active;
34
+ if (entry.outputWritePending === 0) {
35
+ entry.outputViewportAnchor = Number(buffer.viewportY) || 0;
36
+ entry.outputShouldFollow = entry.outputViewportAnchor >= Number(buffer.baseY || 0);
37
+ entry.outputUserScrollRevision = entry.userScrollRevision;
38
+ }
39
+ entry.outputWritePending += 1;
40
+ entry.terminal.write(data, () => {
41
+ entry.outputWritePending = Math.max(0, entry.outputWritePending - 1);
42
+ if (entry.outputWritePending > 0) return;
43
+ if (!entry.outputShouldFollow && entry.outputUserScrollRevision === entry.userScrollRevision) {
44
+ entry.terminal.scrollToLine(entry.outputViewportAnchor);
45
+ }
46
+ });
47
+ };
48
+
12
49
  bindTerminalSessionEvents();
13
50
  bindTmuxEvents();
14
51
  bindTerminalWindowAndPreloadEvents();
15
52
 
16
53
  function bindTerminalSessionEvents() {
17
- $('#newPowerShellBtn').addEventListener('click', () => createTerminal(state.platform.localShell));
18
- $('#newWslBtn').addEventListener('click', () => createTerminal('wsl'));
54
+ $('#newPowerShellBtn').addEventListener('click', event => runBusy(event.currentTarget, () => createTerminal(state.platform.localShell)));
55
+ $('#newWslBtn').addEventListener('click', event => runBusy(event.currentTarget, () => createTerminal('wsl')));
19
56
  $('#newTmuxSessionBtn').addEventListener('click', openTmuxModal);
20
- $('#refreshTmuxTerminalBtn').addEventListener('click', refreshSnapshot);
57
+ $('#refreshTmuxTerminalBtn').addEventListener('click', event => runBusy(event.currentTarget, refreshSnapshot));
21
58
  const sessionList = $('#terminalSessionList');
59
+ const historyList = $('#terminalHistoryList');
60
+ const cancelHistoryFollow = () => {
61
+ state.historyUserRevision += 1;
62
+ if (state.historyFollowFrame) cancelAnimationFrame(state.historyFollowFrame);
63
+ state.historyFollowFrame = 0;
64
+ };
65
+ const flushPendingHistory = () => {
66
+ if (!state.historyRenderPending || state.historyFlushFrame) return;
67
+ state.historyFlushFrame = requestAnimationFrame(() => {
68
+ state.historyFlushFrame = 0;
69
+ if (!state.historyPointerActive) renderHistoryPanel();
70
+ });
71
+ };
72
+ historyList.addEventListener('pointerdown', () => {
73
+ state.historyPointerActive = true;
74
+ cancelHistoryFollow();
75
+ }, true);
76
+ historyList.addEventListener('wheel', cancelHistoryFollow, { capture: true, passive: true });
77
+ historyList.addEventListener('click', () => {
78
+ cancelHistoryFollow();
79
+ flushPendingHistory();
80
+ }, true);
81
+ const finishHistoryPointer = () => {
82
+ state.historyPointerActive = false;
83
+ flushPendingHistory();
84
+ };
85
+ window.addEventListener('pointerup', finishHistoryPointer, true);
86
+ window.addEventListener('pointercancel', finishHistoryPointer, true);
87
+ document.addEventListener('selectionchange', flushPendingHistory);
22
88
  const clearDropMarkers = () => {
23
89
  sessionList.querySelectorAll('.dragging, .drop-before, .drop-after').forEach(item => {
24
90
  item.classList.remove('dragging', 'drop-before', 'drop-after');
@@ -87,6 +153,19 @@ window.LoadToAgentTerminalEvents = function bindTerminalEvents(context) {
87
153
  });
88
154
  sessionList.addEventListener('keydown', event => {
89
155
  const item = event.target.closest('[data-terminal-id]');
156
+ if (item && !event.altKey && ['ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key)) {
157
+ const items = Array.from(sessionList.querySelectorAll('[data-terminal-id]'));
158
+ const current = Math.max(0, items.indexOf(item));
159
+ const next = event.key === 'Home'
160
+ ? 0
161
+ : event.key === 'End'
162
+ ? items.length - 1
163
+ : (current + (event.key === 'ArrowDown' ? 1 : -1) + items.length) % items.length;
164
+ event.preventDefault();
165
+ items.forEach((candidate, index) => { candidate.tabIndex = index === next ? 0 : -1; });
166
+ items[next]?.focus();
167
+ return;
168
+ }
90
169
  if (!item || !event.altKey || !['ArrowUp', 'ArrowDown'].includes(event.key)) return;
91
170
  event.preventDefault();
92
171
  const changed = moveSessionByOffset(item.dataset.terminalId, event.key === 'ArrowUp' ? -1 : 1);
@@ -99,38 +178,112 @@ window.LoadToAgentTerminalEvents = function bindTerminalEvents(context) {
99
178
  const item = event.target.closest('[data-tmux-distro][data-tmux-pane]');
100
179
  if (item) selectTmux(item.dataset.tmuxDistro, item.dataset.tmuxPane);
101
180
  });
181
+ $('#terminalTmuxList').addEventListener('keydown', event => {
182
+ if (!['ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key)) return;
183
+ const items = Array.from(event.currentTarget.querySelectorAll('[data-tmux-distro][data-tmux-pane]'));
184
+ const current = Math.max(0, items.indexOf(event.target.closest('[data-tmux-distro][data-tmux-pane]')));
185
+ const next = event.key === 'Home'
186
+ ? 0
187
+ : event.key === 'End'
188
+ ? items.length - 1
189
+ : (current + (event.key === 'ArrowDown' ? 1 : -1) + items.length) % items.length;
190
+ event.preventDefault();
191
+ items.forEach((candidate, index) => { candidate.tabIndex = index === next ? 0 : -1; });
192
+ items[next]?.focus();
193
+ });
102
194
  $('#terminalCommandForm').addEventListener('submit', async event => {
103
195
  event.preventDefault();
104
196
  if (state.commandSending) return;
105
197
  const input = $('#terminalCommandInput');
106
198
  const sent = await sendCommand(input.value);
107
- if (!sent) return;
199
+ if (!sent) {
200
+ if ($('#terminalNotice')?.dataset.tone === 'error') $('#terminalNotice').focus({ preventScroll: true });
201
+ return;
202
+ }
203
+ const targetId = currentTargetId();
204
+ const history = state.commandHistory.get(targetId) || [];
205
+ const command = input.value;
206
+ if (command && history[history.length - 1] !== command) state.commandHistory.set(targetId, [...history, command].slice(-100));
108
207
  input.value = '';
109
- state.commandDrafts.delete(currentTargetId());
208
+ state.commandDrafts.delete(targetId);
209
+ state.commandHistoryNavigation = { targetId, index: -1, draft: '' };
210
+ $('#terminalCommandClearBtn').classList.add('hidden');
211
+ $('#terminalCommandCount').classList.remove('warning');
110
212
  input.focus({ preventScroll: true });
111
213
  });
112
214
  $('#terminalCommandInput').addEventListener('input', event => {
113
215
  const targetId = currentTargetId();
114
216
  if (targetId) state.commandDrafts.set(targetId, event.target.value);
217
+ $('#terminalCommandCount').textContent = `${event.target.value.length.toLocaleString()} / 8,000`;
218
+ $('#terminalCommandClearBtn').classList.toggle('hidden', !event.target.value);
219
+ const count = $('#terminalCommandCount');
220
+ const wasWarning = count.dataset.warning === 'true';
221
+ const warning = event.target.value.length >= 7_200;
222
+ count.classList.toggle('warning', warning);
223
+ count.dataset.warning = warning ? 'true' : 'false';
224
+ if (warning && !wasWarning) window.LoadToAgentA11y?.announce(t('quality.command_near_limit', { count: 8_000 - event.target.value.length }));
225
+ state.commandHistoryNavigation = { targetId, index: -1, draft: event.target.value };
115
226
  });
116
227
  $('#terminalCommandInput').addEventListener('keydown', event => {
117
228
  if (event.key === 'Enter' && !event.shiftKey && !event.isComposing && event.keyCode !== 229) {
118
229
  event.preventDefault();
119
230
  $('#terminalCommandForm').requestSubmit();
231
+ return;
232
+ }
233
+ if (event.key === 'Escape' && event.currentTarget.value) {
234
+ event.preventDefault();
235
+ $('#terminalCommandClearBtn').click();
236
+ return;
120
237
  }
238
+ if (!['ArrowUp', 'ArrowDown'].includes(event.key) || event.shiftKey || event.metaKey || event.ctrlKey || event.altKey) return;
239
+ const targetId = currentTargetId();
240
+ const history = state.commandHistory.get(targetId) || [];
241
+ if (!history.length) return;
242
+ event.preventDefault();
243
+ let navigation = state.commandHistoryNavigation;
244
+ if (navigation.targetId !== targetId || navigation.index < 0) navigation = { targetId, index: history.length, draft: event.currentTarget.value };
245
+ if (event.key === 'ArrowUp') navigation.index = Math.max(0, navigation.index - 1);
246
+ else navigation.index = Math.min(history.length, navigation.index + 1);
247
+ event.currentTarget.value = navigation.index >= history.length ? navigation.draft : history[navigation.index];
248
+ state.commandHistoryNavigation = navigation;
249
+ state.commandDrafts.set(targetId, event.currentTarget.value);
250
+ $('#terminalCommandClearBtn').classList.toggle('hidden', !event.currentTarget.value);
251
+ $('#terminalCommandCount').textContent = `${event.currentTarget.value.length.toLocaleString()} / 8,000`;
252
+ const input = event.currentTarget;
253
+ requestAnimationFrame(() => {
254
+ if (input.isConnected) input.setSelectionRange(input.value.length, input.value.length);
255
+ });
256
+ });
257
+ $('#terminalCommandClearBtn').addEventListener('click', () => {
258
+ const input = $('#terminalCommandInput');
259
+ input.value = '';
260
+ const targetId = currentTargetId();
261
+ if (targetId) state.commandDrafts.delete(targetId);
262
+ state.commandHistoryNavigation = { targetId, index: -1, draft: '' };
263
+ $('#terminalCommandClearBtn').classList.add('hidden');
264
+ $('#terminalCommandCount').textContent = '0 / 8,000';
265
+ $('#terminalCommandCount').classList.remove('warning');
266
+ input.focus({ preventScroll: true });
267
+ window.LoadToAgentA11y?.announce(t('quality.terminal_draft_cleared'));
121
268
  });
269
+ $('#terminalFontDecreaseBtn').addEventListener('click', () => setTerminalFontSize(state.terminalFontSize - 1));
270
+ $('#terminalFontIncreaseBtn').addEventListener('click', () => setTerminalFontSize(state.terminalFontSize + 1));
271
+ $('#terminalFocusBtn').addEventListener('click', toggleTerminalFocusMode);
122
272
  document.querySelectorAll('[data-terminal-signal]').forEach(button => button.addEventListener('click', () => sendSignal(button.dataset.terminalSignal)));
123
- $('#terminalRestartBtn').addEventListener('click', async () => {
273
+ $('#terminalRestartBtn').addEventListener('click', async event => {
124
274
  const session = currentSession();
125
275
  if (!session) return;
126
- const restarted = await guarded(() => window.loadtoagent.terminalRestart(session.id), '명령창을 다시 시작했습니다.');
127
- if (restarted) {
128
- const entry = state.terminals.get(session.id);
129
- if (entry) entry.terminal.reset();
130
- await refreshSessions();
131
- }
276
+ await runBusy(event.currentTarget, async () => {
277
+ const restarted = await guarded(() => window.loadtoagent.terminalRestart(session.id), t('terminal.session.restarted'), `terminal-restart:${session.id}`);
278
+ if (restarted) {
279
+ const entry = state.terminals.get(session.id);
280
+ if (entry) entry.terminal.reset();
281
+ await refreshSessions();
282
+ }
283
+ });
284
+ renderAll();
132
285
  });
133
- $('#terminalCloseBtn').addEventListener('click', async () => {
286
+ $('#terminalCloseBtn').addEventListener('click', async event => {
134
287
  const session = currentSession();
135
288
  if (!session) {
136
289
  state.selectedTmux = null;
@@ -138,22 +291,25 @@ window.LoadToAgentTerminalEvents = function bindTerminalEvents(context) {
138
291
  showSelection();
139
292
  return;
140
293
  }
141
- if (session.status === 'running' && !window.confirm(`${session.title} 세션과 실행 중인 프로세스를 끝낼까요?\n이 작업은 터미널을 숨기는 것이 아니라 실제 세션을 종료합니다.`)) return;
142
- const closed = await guarded(() => window.loadtoagent.terminalClose(session.id), '터미널 세션을 종료했습니다.');
143
- if (!closed) return;
144
- const entry = state.terminals.get(session.id);
145
- if (entry) {
146
- entry.terminal.dispose();
147
- entry.host.remove();
148
- state.terminals.delete(session.id);
149
- }
150
- state.commandDrafts.delete(session.id);
151
- state.selectedId = null;
152
- if (state.boundTargetId === session.id) {
153
- state.boundAgent = null;
154
- state.boundTargetId = '';
155
- }
156
- await refreshSessions();
294
+ if (session.status === 'running' && !window.confirm(t('terminal.session.confirm_end', { title: session.title }))) return;
295
+ await runBusy(event.currentTarget, async () => {
296
+ const closed = await guarded(() => window.loadtoagent.terminalClose(session.id), t('terminal.session.ended'), `terminal-close:${session.id}`);
297
+ if (!closed) return;
298
+ const entry = state.terminals.get(session.id);
299
+ if (entry) {
300
+ entry.terminal.dispose();
301
+ entry.host.remove();
302
+ state.terminals.delete(session.id);
303
+ }
304
+ state.commandDrafts.delete(session.id);
305
+ state.selectedId = null;
306
+ if (state.boundTargetId === session.id) {
307
+ state.boundAgent = null;
308
+ state.boundTargetId = '';
309
+ }
310
+ await refreshSessions();
311
+ });
312
+ renderAll();
157
313
  });
158
314
  $('#terminalHistoryToggle').addEventListener('click', () => {
159
315
  state.historyCollapsed = !state.historyCollapsed;
@@ -164,21 +320,26 @@ window.LoadToAgentTerminalEvents = function bindTerminalEvents(context) {
164
320
  }
165
321
 
166
322
  function bindTmuxEvents() {
167
- $('#terminalAttachBtn').addEventListener('click', attachTmux);
323
+ $('#terminalAttachBtn').addEventListener('click', event => runBusy(event.currentTarget, attachTmux));
168
324
  $('#terminalTmuxTools').addEventListener('click', event => {
169
325
  const button = event.target.closest('[data-tmux-manage]');
170
- if (button) manageTmux(button.dataset.tmuxManage);
326
+ if (button) runBusy(button, () => manageTmux(button.dataset.tmuxManage));
171
327
  });
172
328
  $('#terminalTmuxLayout').addEventListener('change', async event => {
173
329
  const remote = currentTmux();
174
330
  if (!remote) return;
175
- const result = await guarded(() => window.loadtoagent.tmuxSelectLayout({ distro: remote.distro.name, target: remote.window.nativeId, layout: event.target.value }), '창 배치를 변경했습니다.');
331
+ const result = await guarded(() => window.loadtoagent.tmuxSelectLayout({ distro: remote.distro.name, target: remote.window.nativeId, layout: event.target.value }), t('terminal.tmux.layout_changed'), `tmux-layout:${remote.window.nativeId}`);
176
332
  if (result) setTimeout(refreshSnapshot, 250);
177
333
  });
178
334
  $('#tmuxCreateForm').addEventListener('submit', async event => {
179
335
  event.preventDefault();
180
336
  const submit = event.currentTarget.querySelector('[type="submit"]');
337
+ if (submit.dataset.busy === 'true') return;
338
+ submit.dataset.busy = 'true';
181
339
  submit.disabled = true;
340
+ submit.setAttribute('aria-busy', 'true');
341
+ $('#closeTmuxCreateBtn').disabled = true;
342
+ $('#cancelTmuxCreateBtn').disabled = true;
182
343
  const error = $('#tmuxCreateError');
183
344
  error.classList.add('hidden');
184
345
  try {
@@ -189,23 +350,55 @@ window.LoadToAgentTerminalEvents = function bindTerminalEvents(context) {
189
350
  command: $('#tmuxCreateCommand').value,
190
351
  });
191
352
  if (result && result.ok) {
192
- closeTmuxModal();
193
- notice('새 여러 창 작업을 만들었습니다.', 'success');
353
+ closeTmuxModal(true);
354
+ notice(t('terminal.tmux.workspace_created'), 'success');
194
355
  setTimeout(refreshSnapshot, 300);
195
356
  } else {
196
- error.textContent = result && result.error || 'tmux 작업을 만들지 못했습니다.';
357
+ error.textContent = result && result.error || t('terminal.tmux.workspace_create_failed');
197
358
  error.classList.remove('hidden');
359
+ error.focus({ preventScroll: true });
198
360
  }
199
361
  } catch (failure) {
200
362
  error.textContent = errorMessage(failure);
201
363
  error.classList.remove('hidden');
364
+ error.focus({ preventScroll: true });
202
365
  } finally {
366
+ delete submit.dataset.busy;
203
367
  submit.disabled = false;
368
+ submit.removeAttribute('aria-busy');
369
+ $('#closeTmuxCreateBtn').disabled = false;
370
+ $('#cancelTmuxCreateBtn').disabled = false;
371
+ }
372
+ });
373
+ $('#tmuxCreateForm').addEventListener('invalid', event => {
374
+ event.target.setAttribute('aria-invalid', 'true');
375
+ }, true);
376
+ $('#tmuxCreateForm').addEventListener('input', event => {
377
+ if (event.target.matches('input, textarea, select') && event.target.checkValidity()) event.target.removeAttribute('aria-invalid');
378
+ });
379
+ $('#tmuxCreateName').addEventListener('blur', event => {
380
+ const normalized = event.target.value.trim().replace(/\s+/g, '-');
381
+ if (normalized !== event.target.value) {
382
+ event.target.value = normalized;
383
+ event.target.dispatchEvent(new Event('input', { bubbles: true }));
384
+ }
385
+ });
386
+ $('#pickTmuxCwdBtn').addEventListener('click', event => runBusy(event.currentTarget, async () => {
387
+ try {
388
+ const folder = await window.loadtoagent.pickWorkspace();
389
+ if (folder) $('#tmuxCreateCwd').value = folder;
390
+ } catch (failure) {
391
+ notice(errorMessage(failure), 'error');
204
392
  }
393
+ }));
394
+ $('#closeTmuxCreateBtn').addEventListener('click', () => closeTmuxModal());
395
+ $('#cancelTmuxCreateBtn').addEventListener('click', () => closeTmuxModal());
396
+ let tmuxBackdropPress = null;
397
+ $('#tmuxCreateModal').addEventListener('pointerdown', event => { tmuxBackdropPress = event.target === event.currentTarget; });
398
+ $('#tmuxCreateModal').addEventListener('click', event => {
399
+ if (event.target === event.currentTarget && tmuxBackdropPress !== false) closeTmuxModal();
400
+ tmuxBackdropPress = null;
205
401
  });
206
- $('#closeTmuxCreateBtn').addEventListener('click', closeTmuxModal);
207
- $('#cancelTmuxCreateBtn').addEventListener('click', closeTmuxModal);
208
- $('#tmuxCreateModal').addEventListener('click', event => { if (event.target === event.currentTarget) closeTmuxModal(); });
209
402
  }
210
403
 
211
404
  function bindTerminalWindowAndPreloadEvents() {
@@ -218,9 +411,9 @@ window.LoadToAgentTerminalEvents = function bindTerminalEvents(context) {
218
411
  });
219
412
  window.loadtoagent.onTerminalData(payload => {
220
413
  const entry = state.terminals.get(payload && payload.id);
221
- if (entry && payload.data) entry.terminal.write(payload.data);
414
+ writeTerminalOutput(entry, payload && payload.data);
222
415
  });
223
416
  window.loadtoagent.onTerminalState(payload => refreshSessions(payload));
224
- window.loadtoagent.onTerminalError(payload => notice(payload && payload.message || '명령창 입력에 실패했습니다.', 'error'));
417
+ window.loadtoagent.onTerminalError(payload => notice(payload && payload.message || t('terminal.error.input_failed'), 'error'));
225
418
  }
226
419
  };