loadtoagent 1.1.0 → 1.3.2

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 +14 -5
  2. package/README.md +7 -0
  3. package/README.zh-CN.md +7 -0
  4. package/docs/PROVIDER-CONTRACTS.md +25 -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 +157 -38
  9. package/package.json +13 -4
  10. package/preload.js +13 -2
  11. package/renderer/app-agent-actions.js +125 -53
  12. package/renderer/app-bootstrap.js +54 -19
  13. package/renderer/app-dashboard.js +196 -55
  14. package/renderer/app-drawer-content.js +48 -44
  15. package/renderer/app-drawer-data.js +26 -13
  16. package/renderer/app-drawer.js +72 -54
  17. package/renderer/app-events-dialogs.js +146 -37
  18. package/renderer/app-events-filters.js +172 -13
  19. package/renderer/app-events-navigation.js +77 -30
  20. package/renderer/app-events-sessions.js +156 -9
  21. package/renderer/app-events.js +2 -1
  22. package/renderer/app-graph-model.js +20 -38
  23. package/renderer/app-graph-orchestration.js +15 -13
  24. package/renderer/app-graph-view.js +228 -113
  25. package/renderer/app-management.js +257 -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 +104 -34
  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 +52 -44
  32. package/renderer/app.js +153 -75
  33. package/renderer/i18n-messages.js +867 -37
  34. package/renderer/i18n.js +104 -0
  35. package/renderer/index.html +154 -67
  36. package/renderer/shared.js +17 -0
  37. package/renderer/styles-agent-map.css +16 -10
  38. package/renderer/styles-cards.css +44 -18
  39. package/renderer/styles-collaboration.css +23 -23
  40. package/renderer/styles-components.css +122 -45
  41. package/renderer/styles-management.css +364 -0
  42. package/renderer/styles-onboarding.css +8 -8
  43. package/renderer/styles-overlays.css +13 -7
  44. package/renderer/styles-product.css +48 -40
  45. package/renderer/styles-quality.css +312 -0
  46. package/renderer/styles-readability.css +1040 -0
  47. package/renderer/styles-responsive-product.css +84 -12
  48. package/renderer/styles-responsive-runtime.css +25 -17
  49. package/renderer/styles-responsive-shell.css +44 -48
  50. package/renderer/styles-responsive-workflows.css +39 -5
  51. package/renderer/styles-run-composer.css +28 -23
  52. package/renderer/styles-runtime-overview.css +285 -0
  53. package/renderer/styles-settings.css +176 -16
  54. package/renderer/styles-terminal.css +374 -37
  55. package/renderer/styles-tmux.css +49 -49
  56. package/renderer/styles-workflow-map.css +362 -15
  57. package/renderer/styles-workflows.css +116 -49
  58. package/renderer/styles.css +34 -19
  59. package/renderer/terminal-agent.js +17 -13
  60. package/renderer/terminal-events.js +253 -37
  61. package/renderer/terminal-workbench.js +208 -89
  62. package/renderer/terminal.js +350 -35
  63. package/src/agentMonitor/claudeParser.js +96 -7
  64. package/src/agentMonitor/codexParser.js +59 -4
  65. package/src/agentMonitor/executionActivity.js +282 -0
  66. package/src/agentMonitor/genericParser.js +56 -13
  67. package/src/agentMonitor/hierarchy.js +17 -0
  68. package/src/agentMonitor/responseIntent.js +51 -0
  69. package/src/agentMonitor.js +21 -3
  70. package/src/agentRunner.js +66 -1
  71. package/src/attentionNotifier.js +14 -10
  72. package/src/automationMonitor.js +258 -0
  73. package/src/contracts.js +10 -1
  74. package/src/ipc/registerAgentIpc.js +9 -3
  75. package/src/ipc/registerAppIpc.js +4 -1
  76. package/src/ipc/registerTerminalIpc.js +12 -14
  77. package/src/macUpdateHelper.js +210 -0
  78. package/src/monitorWorker.js +65 -6
  79. package/src/platformPath.js +80 -0
  80. package/src/processMonitor.js +80 -22
  81. package/src/providerVisibilityStore.js +45 -0
  82. package/src/sessionIntelligence.js +261 -0
  83. package/src/terminalHost.js +453 -0
  84. package/src/terminalHostDaemon.js +61 -0
  85. package/src/terminalManager.js +218 -6
  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,90 @@
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,
12
+ isAiTerminalSession,
10
13
  } = context;
11
14
 
15
+ const runBusy = async (button, action) => {
16
+ if (!button || button.dataset.busy === 'true') return null;
17
+ const wasDisabled = button.disabled;
18
+ button.dataset.busy = 'true';
19
+ button.disabled = true;
20
+ button.setAttribute('aria-busy', 'true');
21
+ try {
22
+ return await action();
23
+ } finally {
24
+ if (button.isConnected) {
25
+ delete button.dataset.busy;
26
+ button.disabled = wasDisabled;
27
+ button.removeAttribute('aria-busy');
28
+ }
29
+ }
30
+ };
31
+
32
+ const writeTerminalOutput = (entry, data) => {
33
+ if (!entry || !data) return;
34
+ const buffer = entry.terminal.buffer.active;
35
+ if (entry.outputWritePending === 0) {
36
+ entry.outputViewportAnchor = Number(buffer.viewportY) || 0;
37
+ entry.outputShouldFollow = entry.outputViewportAnchor >= Number(buffer.baseY || 0);
38
+ entry.outputUserScrollRevision = entry.userScrollRevision;
39
+ }
40
+ entry.outputWritePending += 1;
41
+ entry.terminal.write(data, () => {
42
+ entry.outputWritePending = Math.max(0, entry.outputWritePending - 1);
43
+ if (entry.outputWritePending > 0) return;
44
+ if (!entry.outputShouldFollow && entry.outputUserScrollRevision === entry.userScrollRevision) {
45
+ entry.terminal.scrollToLine(entry.outputViewportAnchor);
46
+ }
47
+ });
48
+ };
49
+
12
50
  bindTerminalSessionEvents();
13
51
  bindTmuxEvents();
14
52
  bindTerminalWindowAndPreloadEvents();
15
53
 
16
54
  function bindTerminalSessionEvents() {
17
- $('#newPowerShellBtn').addEventListener('click', () => createTerminal(state.platform.localShell));
18
- $('#newWslBtn').addEventListener('click', () => createTerminal('wsl'));
55
+ $('#newPowerShellBtn').addEventListener('click', event => runBusy(event.currentTarget, () => createTerminal(state.platform.localShell)));
56
+ $('#newWslBtn').addEventListener('click', event => runBusy(event.currentTarget, () => createTerminal('wsl')));
19
57
  $('#newTmuxSessionBtn').addEventListener('click', openTmuxModal);
20
- $('#refreshTmuxTerminalBtn').addEventListener('click', refreshSnapshot);
58
+ $('#refreshTmuxTerminalBtn').addEventListener('click', event => runBusy(event.currentTarget, refreshSnapshot));
21
59
  const sessionList = $('#terminalSessionList');
60
+ const historyList = $('#terminalHistoryList');
61
+ const cancelHistoryFollow = () => {
62
+ state.historyUserRevision += 1;
63
+ if (state.historyFollowFrame) cancelAnimationFrame(state.historyFollowFrame);
64
+ state.historyFollowFrame = 0;
65
+ };
66
+ const flushPendingHistory = () => {
67
+ if (!state.historyRenderPending || state.historyFlushFrame) return;
68
+ state.historyFlushFrame = requestAnimationFrame(() => {
69
+ state.historyFlushFrame = 0;
70
+ if (!state.historyPointerActive) renderHistoryPanel();
71
+ });
72
+ };
73
+ historyList.addEventListener('pointerdown', () => {
74
+ state.historyPointerActive = true;
75
+ cancelHistoryFollow();
76
+ }, true);
77
+ historyList.addEventListener('wheel', cancelHistoryFollow, { capture: true, passive: true });
78
+ historyList.addEventListener('click', () => {
79
+ cancelHistoryFollow();
80
+ flushPendingHistory();
81
+ }, true);
82
+ const finishHistoryPointer = () => {
83
+ state.historyPointerActive = false;
84
+ flushPendingHistory();
85
+ };
86
+ window.addEventListener('pointerup', finishHistoryPointer, true);
87
+ window.addEventListener('pointercancel', finishHistoryPointer, true);
88
+ document.addEventListener('selectionchange', flushPendingHistory);
22
89
  const clearDropMarkers = () => {
23
90
  sessionList.querySelectorAll('.dragging, .drop-before, .drop-after').forEach(item => {
24
91
  item.classList.remove('dragging', 'drop-before', 'drop-after');
@@ -87,6 +154,19 @@ window.LoadToAgentTerminalEvents = function bindTerminalEvents(context) {
87
154
  });
88
155
  sessionList.addEventListener('keydown', event => {
89
156
  const item = event.target.closest('[data-terminal-id]');
157
+ if (item && !event.altKey && ['ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key)) {
158
+ const items = Array.from(sessionList.querySelectorAll('[data-terminal-id]'));
159
+ const current = Math.max(0, items.indexOf(item));
160
+ const next = event.key === 'Home'
161
+ ? 0
162
+ : event.key === 'End'
163
+ ? items.length - 1
164
+ : (current + (event.key === 'ArrowDown' ? 1 : -1) + items.length) % items.length;
165
+ event.preventDefault();
166
+ items.forEach((candidate, index) => { candidate.tabIndex = index === next ? 0 : -1; });
167
+ items[next]?.focus();
168
+ return;
169
+ }
90
170
  if (!item || !event.altKey || !['ArrowUp', 'ArrowDown'].includes(event.key)) return;
91
171
  event.preventDefault();
92
172
  const changed = moveSessionByOffset(item.dataset.terminalId, event.key === 'ArrowUp' ? -1 : 1);
@@ -99,62 +179,157 @@ window.LoadToAgentTerminalEvents = function bindTerminalEvents(context) {
99
179
  const item = event.target.closest('[data-tmux-distro][data-tmux-pane]');
100
180
  if (item) selectTmux(item.dataset.tmuxDistro, item.dataset.tmuxPane);
101
181
  });
182
+ $('#terminalTmuxList').addEventListener('keydown', event => {
183
+ if (!['ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key)) return;
184
+ const items = Array.from(event.currentTarget.querySelectorAll('[data-tmux-distro][data-tmux-pane]'));
185
+ const current = Math.max(0, items.indexOf(event.target.closest('[data-tmux-distro][data-tmux-pane]')));
186
+ const next = event.key === 'Home'
187
+ ? 0
188
+ : event.key === 'End'
189
+ ? items.length - 1
190
+ : (current + (event.key === 'ArrowDown' ? 1 : -1) + items.length) % items.length;
191
+ event.preventDefault();
192
+ items.forEach((candidate, index) => { candidate.tabIndex = index === next ? 0 : -1; });
193
+ items[next]?.focus();
194
+ });
102
195
  $('#terminalCommandForm').addEventListener('submit', async event => {
103
196
  event.preventDefault();
104
197
  if (state.commandSending) return;
105
198
  const input = $('#terminalCommandInput');
106
199
  const sent = await sendCommand(input.value);
107
- if (!sent) return;
200
+ if (!sent) {
201
+ if ($('#terminalNotice')?.dataset.tone === 'error') $('#terminalNotice').focus({ preventScroll: true });
202
+ return;
203
+ }
204
+ const targetId = currentTargetId();
205
+ const history = state.commandHistory.get(targetId) || [];
206
+ const command = input.value;
207
+ if (command && history[history.length - 1] !== command) state.commandHistory.set(targetId, [...history, command].slice(-100));
108
208
  input.value = '';
109
- state.commandDrafts.delete(currentTargetId());
209
+ state.commandDrafts.delete(targetId);
210
+ state.commandHistoryNavigation = { targetId, index: -1, draft: '' };
211
+ $('#terminalCommandClearBtn').classList.add('hidden');
212
+ $('#terminalCommandCount').classList.remove('warning');
110
213
  input.focus({ preventScroll: true });
111
214
  });
112
215
  $('#terminalCommandInput').addEventListener('input', event => {
113
216
  const targetId = currentTargetId();
114
217
  if (targetId) state.commandDrafts.set(targetId, event.target.value);
218
+ $('#terminalCommandCount').textContent = `${event.target.value.length.toLocaleString()} / 8,000`;
219
+ $('#terminalCommandClearBtn').classList.toggle('hidden', !event.target.value);
220
+ const count = $('#terminalCommandCount');
221
+ const wasWarning = count.dataset.warning === 'true';
222
+ const warning = event.target.value.length >= 7_200;
223
+ count.classList.toggle('warning', warning);
224
+ count.dataset.warning = warning ? 'true' : 'false';
225
+ if (warning && !wasWarning) window.LoadToAgentA11y?.announce(t('quality.command_near_limit', { count: 8_000 - event.target.value.length }));
226
+ state.commandHistoryNavigation = { targetId, index: -1, draft: event.target.value };
115
227
  });
116
228
  $('#terminalCommandInput').addEventListener('keydown', event => {
117
229
  if (event.key === 'Enter' && !event.shiftKey && !event.isComposing && event.keyCode !== 229) {
118
230
  event.preventDefault();
119
231
  $('#terminalCommandForm').requestSubmit();
232
+ return;
120
233
  }
234
+ if (event.key === 'Escape' && event.currentTarget.value) {
235
+ event.preventDefault();
236
+ $('#terminalCommandClearBtn').click();
237
+ return;
238
+ }
239
+ if (!['ArrowUp', 'ArrowDown'].includes(event.key) || event.shiftKey || event.metaKey || event.ctrlKey || event.altKey) return;
240
+ const targetId = currentTargetId();
241
+ const history = state.commandHistory.get(targetId) || [];
242
+ if (!history.length) return;
243
+ event.preventDefault();
244
+ let navigation = state.commandHistoryNavigation;
245
+ if (navigation.targetId !== targetId || navigation.index < 0) navigation = { targetId, index: history.length, draft: event.currentTarget.value };
246
+ if (event.key === 'ArrowUp') navigation.index = Math.max(0, navigation.index - 1);
247
+ else navigation.index = Math.min(history.length, navigation.index + 1);
248
+ event.currentTarget.value = navigation.index >= history.length ? navigation.draft : history[navigation.index];
249
+ state.commandHistoryNavigation = navigation;
250
+ state.commandDrafts.set(targetId, event.currentTarget.value);
251
+ $('#terminalCommandClearBtn').classList.toggle('hidden', !event.currentTarget.value);
252
+ $('#terminalCommandCount').textContent = `${event.currentTarget.value.length.toLocaleString()} / 8,000`;
253
+ const input = event.currentTarget;
254
+ requestAnimationFrame(() => {
255
+ if (input.isConnected) input.setSelectionRange(input.value.length, input.value.length);
256
+ });
257
+ });
258
+ $('#terminalCommandClearBtn').addEventListener('click', () => {
259
+ const input = $('#terminalCommandInput');
260
+ input.value = '';
261
+ const targetId = currentTargetId();
262
+ if (targetId) state.commandDrafts.delete(targetId);
263
+ state.commandHistoryNavigation = { targetId, index: -1, draft: '' };
264
+ $('#terminalCommandClearBtn').classList.add('hidden');
265
+ $('#terminalCommandCount').textContent = '0 / 8,000';
266
+ $('#terminalCommandCount').classList.remove('warning');
267
+ input.focus({ preventScroll: true });
268
+ window.LoadToAgentA11y?.announce(t('quality.terminal_draft_cleared'));
121
269
  });
270
+ $('#terminalFontDecreaseBtn').addEventListener('click', () => setTerminalFontSize(state.terminalFontSize - 1));
271
+ $('#terminalFontIncreaseBtn').addEventListener('click', () => setTerminalFontSize(state.terminalFontSize + 1));
272
+ $('#terminalFocusBtn').addEventListener('click', toggleTerminalFocusMode);
122
273
  document.querySelectorAll('[data-terminal-signal]').forEach(button => button.addEventListener('click', () => sendSignal(button.dataset.terminalSignal)));
123
- $('#terminalRestartBtn').addEventListener('click', async () => {
274
+ $('#terminalRestartBtn').addEventListener('click', async event => {
124
275
  const session = currentSession();
125
276
  if (!session) return;
126
- const restarted = await guarded(() => window.loadtoagent.terminalRestart(session.id), '명령창을 다시 시작했습니다.');
127
- if (restarted) {
277
+ await runBusy(event.currentTarget, async () => {
278
+ const restarted = await guarded(() => window.loadtoagent.terminalRestart(session.id), t('terminal.session.restarted'), `terminal-restart:${session.id}`);
279
+ if (restarted) {
280
+ const entry = state.terminals.get(session.id);
281
+ if (entry) entry.terminal.reset();
282
+ await refreshSessions();
283
+ }
284
+ });
285
+ renderAll();
286
+ });
287
+ const endTerminalSession = async (button, session) => {
288
+ if (!session) return;
289
+ const confirmation = isAiTerminalSession(session) ? 'terminal.session.confirm_end_ai' : 'terminal.session.confirm_end';
290
+ if (session.type !== 'tmux' && session.status === 'running' && !window.confirm(t(confirmation, { title: session.title }))) return;
291
+ await runBusy(button, async () => {
292
+ const message = session.type === 'tmux' ? t('terminal.tmux.detached_input') : t('terminal.session.ended');
293
+ const closed = await guarded(() => window.loadtoagent.terminalClose(session.id), message, `terminal-close:${session.id}`);
294
+ if (!closed) return;
128
295
  const entry = state.terminals.get(session.id);
129
- if (entry) entry.terminal.reset();
296
+ if (entry) {
297
+ entry.terminal.dispose();
298
+ entry.host.remove();
299
+ state.terminals.delete(session.id);
300
+ }
301
+ state.commandDrafts.delete(session.id);
302
+ state.selectedId = null;
303
+ if (state.boundTargetId === session.id) {
304
+ state.boundAgent = null;
305
+ state.boundTargetId = '';
306
+ }
130
307
  await refreshSessions();
131
- }
132
- });
133
- $('#terminalCloseBtn').addEventListener('click', async () => {
308
+ });
309
+ renderAll();
310
+ };
311
+ $('#terminalCloseBtn').addEventListener('click', async event => {
134
312
  const session = currentSession();
135
313
  if (!session) {
136
314
  state.selectedTmux = null;
137
315
  renderAll();
138
- showSelection();
316
+ await showSelection();
139
317
  return;
140
318
  }
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) {
319
+ if (isAiTerminalSession(session)) {
320
+ state.captureGeneration += 1;
321
+ state.selectedId = null;
153
322
  state.boundAgent = null;
154
323
  state.boundTargetId = '';
324
+ renderAll();
325
+ renderHistoryPanel();
326
+ await showSelection();
327
+ notice(t('terminal.view_closed_ai_kept'), 'success');
328
+ return;
155
329
  }
156
- await refreshSessions();
330
+ await endTerminalSession(event.currentTarget, session);
157
331
  });
332
+ $('#terminalEndSessionBtn').addEventListener('click', event => endTerminalSession(event.currentTarget, currentSession()));
158
333
  $('#terminalHistoryToggle').addEventListener('click', () => {
159
334
  state.historyCollapsed = !state.historyCollapsed;
160
335
  renderHistoryPanel();
@@ -164,21 +339,26 @@ window.LoadToAgentTerminalEvents = function bindTerminalEvents(context) {
164
339
  }
165
340
 
166
341
  function bindTmuxEvents() {
167
- $('#terminalAttachBtn').addEventListener('click', attachTmux);
342
+ $('#terminalAttachBtn').addEventListener('click', event => runBusy(event.currentTarget, attachTmux));
168
343
  $('#terminalTmuxTools').addEventListener('click', event => {
169
344
  const button = event.target.closest('[data-tmux-manage]');
170
- if (button) manageTmux(button.dataset.tmuxManage);
345
+ if (button) runBusy(button, () => manageTmux(button.dataset.tmuxManage));
171
346
  });
172
347
  $('#terminalTmuxLayout').addEventListener('change', async event => {
173
348
  const remote = currentTmux();
174
349
  if (!remote) return;
175
- const result = await guarded(() => window.loadtoagent.tmuxSelectLayout({ distro: remote.distro.name, target: remote.window.nativeId, layout: event.target.value }), '창 배치를 변경했습니다.');
350
+ 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
351
  if (result) setTimeout(refreshSnapshot, 250);
177
352
  });
178
353
  $('#tmuxCreateForm').addEventListener('submit', async event => {
179
354
  event.preventDefault();
180
355
  const submit = event.currentTarget.querySelector('[type="submit"]');
356
+ if (submit.dataset.busy === 'true') return;
357
+ submit.dataset.busy = 'true';
181
358
  submit.disabled = true;
359
+ submit.setAttribute('aria-busy', 'true');
360
+ $('#closeTmuxCreateBtn').disabled = true;
361
+ $('#cancelTmuxCreateBtn').disabled = true;
182
362
  const error = $('#tmuxCreateError');
183
363
  error.classList.add('hidden');
184
364
  try {
@@ -189,23 +369,55 @@ window.LoadToAgentTerminalEvents = function bindTerminalEvents(context) {
189
369
  command: $('#tmuxCreateCommand').value,
190
370
  });
191
371
  if (result && result.ok) {
192
- closeTmuxModal();
193
- notice('새 여러 창 작업을 만들었습니다.', 'success');
372
+ closeTmuxModal(true);
373
+ notice(t('terminal.tmux.workspace_created'), 'success');
194
374
  setTimeout(refreshSnapshot, 300);
195
375
  } else {
196
- error.textContent = result && result.error || 'tmux 작업을 만들지 못했습니다.';
376
+ error.textContent = result && result.error || t('terminal.tmux.workspace_create_failed');
197
377
  error.classList.remove('hidden');
378
+ error.focus({ preventScroll: true });
198
379
  }
199
380
  } catch (failure) {
200
381
  error.textContent = errorMessage(failure);
201
382
  error.classList.remove('hidden');
383
+ error.focus({ preventScroll: true });
202
384
  } finally {
385
+ delete submit.dataset.busy;
203
386
  submit.disabled = false;
387
+ submit.removeAttribute('aria-busy');
388
+ $('#closeTmuxCreateBtn').disabled = false;
389
+ $('#cancelTmuxCreateBtn').disabled = false;
204
390
  }
205
391
  });
206
- $('#closeTmuxCreateBtn').addEventListener('click', closeTmuxModal);
207
- $('#cancelTmuxCreateBtn').addEventListener('click', closeTmuxModal);
208
- $('#tmuxCreateModal').addEventListener('click', event => { if (event.target === event.currentTarget) closeTmuxModal(); });
392
+ $('#tmuxCreateForm').addEventListener('invalid', event => {
393
+ event.target.setAttribute('aria-invalid', 'true');
394
+ }, true);
395
+ $('#tmuxCreateForm').addEventListener('input', event => {
396
+ if (event.target.matches('input, textarea, select') && event.target.checkValidity()) event.target.removeAttribute('aria-invalid');
397
+ });
398
+ $('#tmuxCreateName').addEventListener('blur', event => {
399
+ const normalized = event.target.value.trim().replace(/\s+/g, '-');
400
+ if (normalized !== event.target.value) {
401
+ event.target.value = normalized;
402
+ event.target.dispatchEvent(new Event('input', { bubbles: true }));
403
+ }
404
+ });
405
+ $('#pickTmuxCwdBtn').addEventListener('click', event => runBusy(event.currentTarget, async () => {
406
+ try {
407
+ const folder = await window.loadtoagent.pickWorkspace();
408
+ if (folder) $('#tmuxCreateCwd').value = folder;
409
+ } catch (failure) {
410
+ notice(errorMessage(failure), 'error');
411
+ }
412
+ }));
413
+ $('#closeTmuxCreateBtn').addEventListener('click', () => closeTmuxModal());
414
+ $('#cancelTmuxCreateBtn').addEventListener('click', () => closeTmuxModal());
415
+ let tmuxBackdropPress = null;
416
+ $('#tmuxCreateModal').addEventListener('pointerdown', event => { tmuxBackdropPress = event.target === event.currentTarget; });
417
+ $('#tmuxCreateModal').addEventListener('click', event => {
418
+ if (event.target === event.currentTarget && tmuxBackdropPress !== false) closeTmuxModal();
419
+ tmuxBackdropPress = null;
420
+ });
209
421
  }
210
422
 
211
423
  function bindTerminalWindowAndPreloadEvents() {
@@ -218,9 +430,13 @@ window.LoadToAgentTerminalEvents = function bindTerminalEvents(context) {
218
430
  });
219
431
  window.loadtoagent.onTerminalData(payload => {
220
432
  const entry = state.terminals.get(payload && payload.id);
221
- if (entry && payload.data) entry.terminal.write(payload.data);
433
+ writeTerminalOutput(entry, payload && payload.data);
222
434
  });
223
435
  window.loadtoagent.onTerminalState(payload => refreshSessions(payload));
224
- window.loadtoagent.onTerminalError(payload => notice(payload && payload.message || '명령창 입력에 실패했습니다.', 'error'));
436
+ window.loadtoagent.onTerminalError(payload => notice(payload && payload.message || t('terminal.error.input_failed'), 'error'));
437
+ window.loadtoagent.onTerminalConnection?.(payload => {
438
+ const tone = payload?.state === 'failed' ? 'error' : payload?.state === 'connected' ? 'success' : 'info';
439
+ notice(payload?.message || t('terminal.error.input_failed'), tone);
440
+ });
225
441
  }
226
442
  };