loadtoagent 1.1.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 (83) 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 +135 -38
  9. package/package.json +13 -4
  10. package/preload.js +6 -0
  11. package/renderer/app-agent-actions.js +125 -53
  12. package/renderer/app-bootstrap.js +54 -19
  13. package/renderer/app-dashboard.js +190 -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 +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 +52 -44
  32. package/renderer/app.js +153 -75
  33. package/renderer/i18n-messages.js +833 -16
  34. package/renderer/i18n.js +104 -0
  35. package/renderer/index.html +145 -59
  36. package/renderer/shared.js +17 -0
  37. package/renderer/styles-agent-map.css +6 -0
  38. package/renderer/styles-cards.css +26 -0
  39. package/renderer/styles-components.css +86 -9
  40. package/renderer/styles-management.css +355 -0
  41. package/renderer/styles-overlays.css +8 -2
  42. package/renderer/styles-product.css +24 -16
  43. package/renderer/styles-quality.css +312 -0
  44. package/renderer/styles-readability.css +862 -0
  45. package/renderer/styles-responsive-product.css +84 -12
  46. package/renderer/styles-responsive-runtime.css +22 -14
  47. package/renderer/styles-responsive-shell.css +44 -48
  48. package/renderer/styles-responsive-workflows.css +37 -3
  49. package/renderer/styles-run-composer.css +5 -0
  50. package/renderer/styles-runtime-overview.css +285 -0
  51. package/renderer/styles-settings.css +160 -0
  52. package/renderer/styles-terminal.css +339 -2
  53. package/renderer/styles-workflow-map.css +362 -15
  54. package/renderer/styles-workflows.css +69 -2
  55. package/renderer/styles.css +27 -12
  56. package/renderer/terminal-agent.js +17 -13
  57. package/renderer/terminal-events.js +233 -40
  58. package/renderer/terminal-workbench.js +165 -77
  59. package/renderer/terminal.js +341 -34
  60. package/src/agentMonitor/claudeParser.js +96 -7
  61. package/src/agentMonitor/codexParser.js +59 -4
  62. package/src/agentMonitor/executionActivity.js +282 -0
  63. package/src/agentMonitor/genericParser.js +56 -13
  64. package/src/agentMonitor/hierarchy.js +17 -0
  65. package/src/agentMonitor/responseIntent.js +51 -0
  66. package/src/agentMonitor.js +21 -3
  67. package/src/agentRunner.js +66 -1
  68. package/src/attentionNotifier.js +14 -10
  69. package/src/automationMonitor.js +258 -0
  70. package/src/contracts.js +10 -1
  71. package/src/ipc/registerAgentIpc.js +9 -3
  72. package/src/ipc/registerAppIpc.js +4 -1
  73. package/src/ipc/registerTerminalIpc.js +12 -6
  74. package/src/macUpdateHelper.js +210 -0
  75. package/src/monitorWorker.js +65 -6
  76. package/src/platformPath.js +80 -0
  77. package/src/processMonitor.js +80 -22
  78. package/src/providerVisibilityStore.js +45 -0
  79. package/src/sessionIntelligence.js +247 -0
  80. package/src/terminalHost.js +381 -0
  81. package/src/terminalHostDaemon.js +60 -0
  82. package/src/terminalManager.js +158 -3
  83. package/src/updateInstaller.js +175 -0
@@ -2,6 +2,7 @@
2
2
 
3
3
  /** Own xterm views, terminal/tmux selection, capture, and management actions. */
4
4
  window.LoadToAgentTerminalWorkbench = function createModule(context) {
5
+ const t = (key, params) => window.LoadToAgentI18n.t(key, params);
5
6
  const {
6
7
  $, state, notice, setConnectionState, currentSession, currentTmux, saveCurrentDraft, restoreCurrentDraft,
7
8
  renderHistoryPanel, terminalTypeMark, terminalTypeLabel, xtermOptions, preferredWorkspace, firstDistro, guarded,
@@ -9,7 +10,7 @@ window.LoadToAgentTerminalWorkbench = function createModule(context) {
9
10
  } = context;
10
11
 
11
12
  function createXtermHost(key, readOnly = false) {
12
- if (!window.Terminal || !window.FitAddon || !window.FitAddon.FitAddon) throw new Error('명령창 화면을 불러오지 못했습니다.');
13
+ if (!window.Terminal || !window.FitAddon || !window.FitAddon.FitAddon) throw new Error(t('terminal.error.screen_unavailable'));
13
14
  const host = document.createElement('div');
14
15
  host.className = 'terminal-screen hidden';
15
16
  host.dataset.terminalScreen = key;
@@ -18,26 +19,29 @@ window.LoadToAgentTerminalWorkbench = function createModule(context) {
18
19
  const fit = new window.FitAddon.FitAddon();
19
20
  terminal.loadAddon(fit);
20
21
  terminal.open(host);
22
+ const entry = { terminal, fit, host, readOnly, userScrollRevision: 0, outputWritePending: 0 };
21
23
  const syncScrollState = viewportY => {
22
- host.dataset.viewportY = String(Number(viewportY) || 0);
23
- host.dataset.baseY = String(Number(terminal.buffer.active.baseY) || 0);
24
+ const normalizedViewport = Number(viewportY) || 0;
25
+ const baseY = Number(terminal.buffer.active.baseY) || 0;
26
+ host.dataset.viewportY = String(normalizedViewport);
27
+ host.dataset.baseY = String(baseY);
28
+ // Xterm may consume wheel events before they bubble to the host. Its
29
+ // scroll event is the reliable source for mouse, keyboard and scrollbar
30
+ // viewport changes.
31
+ if (readOnly && !state.remoteCaptureApplying) {
32
+ state.remoteViewportAnchor = normalizedViewport;
33
+ state.remoteViewportAtBottom = normalizedViewport >= baseY;
34
+ }
24
35
  };
25
36
  terminal.onScroll(syncScrollState);
26
37
  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
38
  if (!readOnly) {
39
+ const rememberUserScroll = () => { entry.userScrollRevision += 1; };
40
+ host.addEventListener('wheel', rememberUserScroll, { capture: true, passive: true });
41
+ host.addEventListener('pointerup', rememberUserScroll, true);
42
+ host.addEventListener('keyup', event => {
43
+ if (['PageUp', 'PageDown', 'Home', 'End', 'ArrowUp', 'ArrowDown'].includes(event.key)) rememberUserScroll();
44
+ }, true);
41
45
  terminal.onData(data => {
42
46
  if (state.selectedId === key) Promise.resolve(window.loadtoagent.terminalWrite(key, data)).catch(error => notice(errorMessage(error), 'error'));
43
47
  });
@@ -88,49 +92,97 @@ window.LoadToAgentTerminalWorkbench = function createModule(context) {
88
92
  $('#terminalEmpty').classList.add('hidden');
89
93
  }
90
94
 
95
+ function linkedAgentSession(session) {
96
+ if (!session) return null;
97
+ if (state.boundTargetId === session.id && state.boundAgent) return state.boundAgent;
98
+ const bridgeId = String(session.bridgeId || '');
99
+ return bridgeId && Array.isArray(state.snapshot?.sessions)
100
+ ? state.snapshot.sessions.find(item => item.id === bridgeId) || null
101
+ : null;
102
+ }
103
+
104
+ function terminalPresentation(session) {
105
+ const agent = linkedAgentSession(session);
106
+ if (agent?.attention?.required || agent?.status === 'waiting') return { tone: 'attention', label: t('app.nav.needs_review') };
107
+ if (agent?.status === 'failed' || session?.status === 'failed') return { tone: 'failed', label: t('terminal.status.failed') };
108
+ if (agent?.status === 'completed') return { tone: 'completed', label: t('ui.completed') };
109
+ if (agent && ['running', 'starting'].includes(agent.status)) return { tone: 'running', label: t('ui.working') };
110
+ if (session?.status === 'running' || session?.status === 'starting') return { tone: 'running', label: STATUS_LABELS[session.status] || session.status };
111
+ return { tone: session?.status || 'idle', label: STATUS_LABELS[session?.status] || session?.status || t('ui.idle') };
112
+ }
113
+
91
114
  function renderSessions() {
92
115
  const general = modeSessions('general');
93
116
  const running = general.filter(item => item.status === 'running').length;
94
117
  const background = general.filter(item => item.background && item.status === 'running').length;
118
+ const attention = general.filter(item => terminalPresentation(item).tone === 'attention').length;
95
119
  $('#navTerminalCount').textContent = running;
120
+ const terminalNav = document.querySelector('.nav-item[data-view="terminal"]');
121
+ if (terminalNav) terminalNav.setAttribute('aria-label', t('quality.nav_count_detailed', { label: t('app.nav.session_terminal'), count: running, unit: t('quality.unit.sessions') }));
122
+ const advancedCount = ['navRuntimeCount', 'navTerminalCount', 'navTmuxCount']
123
+ .reduce((total, id) => total + Number(document.getElementById(id)?.textContent || 0), 0);
124
+ const advancedCounter = document.getElementById('advancedToolsCount');
125
+ if (advancedCounter) advancedCounter.textContent = String(advancedCount);
126
+ document.querySelector('#advancedToolsNav > summary')?.setAttribute('aria-label', t('quality.nav_count_detailed', {
127
+ label: t('management.advanced_tools'), count: advancedCount, unit: t('quality.unit.items'),
128
+ }));
96
129
  $('#terminalSessionSummary').textContent = [
97
130
  window.LoadToAgentI18n.t('common.active', { count: running }),
131
+ attention ? t('terminal.monitor.attention_count', { count: attention }) : '',
98
132
  background ? window.LoadToAgentI18n.t('session.background_count', { count: background }) : '',
99
133
  window.LoadToAgentI18n.t('session.total_count', { count: general.length }),
100
134
  ].filter(Boolean).join(' · ');
101
- $('#terminalSessionList').innerHTML = general.length ? general.map((session, index) => `
135
+ const renderKey = JSON.stringify([
136
+ state.selectedId,
137
+ general.map(session => {
138
+ const presentation = terminalPresentation(session);
139
+ return [session.id, session.title, session.type, session.status, session.cwd, session.background, presentation.tone, presentation.label];
140
+ }),
141
+ ]);
142
+ if (renderKey === state.sessionRenderKey) return;
143
+ state.sessionRenderKey = renderKey;
144
+ $('#terminalSessionList').innerHTML = general.length ? general.map((session, index) => {
145
+ const presentation = terminalPresentation(session);
146
+ return `
102
147
  <div class="terminal-session-row">
103
148
  <button type="button" draggable="true"
104
149
  class="terminal-session-item ${state.selectedId === session.id ? 'active' : ''}"
150
+ data-status="${esc(presentation.tone)}"
105
151
  data-terminal-id="${esc(session.id)}"
152
+ role="option"
153
+ aria-selected="${state.selectedId === session.id ? 'true' : 'false'}"
154
+ tabindex="${state.selectedId === session.id || (!state.selectedId && index === 0) ? '0' : '-1'}"
155
+ aria-pressed="${state.selectedId === session.id ? 'true' : 'false'}"
106
156
  aria-grabbed="false"
107
157
  aria-describedby="terminalReorderHelp"
108
- title="${esc(window.LoadToAgentI18n.t('terminal.reorder_hint'))}">
158
+ title="${esc(session.cwd || window.LoadToAgentI18n.t('terminal.reorder_hint'))}">
109
159
  <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>
160
+ <span><b>${esc(session.title)}</b><small>${esc(terminalTypeLabel(session))}${session.background ? ` · ${t('terminal.background_kept')}` : ''}</small><em>${esc(session.cwd || session.distro || `PID ${session.pid || '--'}`)}</em><span class="sr-only">${index + 1}/${general.length}</span></span>
161
+ <span class="terminal-session-status" data-status="${esc(presentation.tone)}"><i></i>${esc(presentation.label)}</span>
112
162
  </button>
113
163
  <span class="terminal-reorder-actions" aria-label="${esc(window.LoadToAgentI18n.t('terminal.reorder_group', { title: session.title }))}">
114
164
  <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
165
  <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
166
  </span>
117
- </div>`).join('') : '<div class="terminal-resource-empty">열어 둔 일반 명령창이 없습니다.</div>';
167
+ </div>`;
168
+ }).join('') : `<div class="terminal-resource-empty">${t('terminal.empty.general')}</div>`;
118
169
  }
119
170
 
120
171
  function renderTmuxResources() {
121
172
  const distros = state.snapshot && state.snapshot.tmux && state.snapshot.tmux.distros || [];
122
173
  if (!distros.length) {
123
- $('#terminalTmuxList').innerHTML = '<div class="terminal-resource-empty">Linux에서 나눠 실행 중인 작업이 없습니다.</div>';
174
+ $('#terminalTmuxList').innerHTML = `<div class="terminal-resource-empty">${t('terminal.empty.tmux')}</div>`;
124
175
  return;
125
176
  }
177
+ let paneIndex = 0;
126
178
  $('#terminalTmuxList').innerHTML = distros.map(distro => `
127
179
  <section class="terminal-tmux-group">
128
- <header><b>${esc(distro.name)}</b><span>작업 묶음 ${(distro.sessions || []).length}개</span></header>
180
+ <header><b>${esc(distro.name)}</b><span>${t('terminal.tmux.workspace_count', { count: (distro.sessions || []).length })}</span></header>
129
181
  ${(distro.sessions || []).map(session => `
130
- <div class="terminal-tmux-session"><strong>${esc(session.name)}</strong><small>${session.attached ? '화면에 연결됨' : '뒤에서 실행 중'}</small></div>
182
+ <div class="terminal-tmux-session"><strong>${esc(session.name)}</strong><small>${session.attached ? t('terminal.tmux.attached') : t('terminal.tmux.running_background')}</small></div>
131
183
  ${(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>
184
+ <button type="button" role="option" 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)}" aria-selected="${state.selectedTmux && state.selectedTmux.distro.name === distro.name && state.selectedTmux.pane.nativeId === pane.nativeId ? 'true' : 'false'}" aria-pressed="${state.selectedTmux && state.selectedTmux.distro.name === distro.name && state.selectedTmux.pane.nativeId === pane.nativeId ? 'true' : 'false'}" tabindex="${state.selectedTmux && state.selectedTmux.distro.name === distro.name && state.selectedTmux.pane.nativeId === pane.nativeId || (!state.selectedTmux && paneIndex === 0) ? '0' : '-1'}" data-pane-index="${paneIndex++}">
185
+ <span><b>${esc(pane.nativeId)} · ${esc(windowItem.index)}:${esc(windowItem.name)}</b><small>${esc(pane.command || 'shell')} · ${esc(pane.cwd || t('terminal.path_unreported'))}</small></span>
134
186
  <i class="${pane.agent ? 'agent' : (pane.active ? 'live' : '')}">${pane.agent ? 'AI' : (pane.active ? 'ON' : '')}</i>
135
187
  </button>`)).join('')}`).join('')}
136
188
  </section>`).join('');
@@ -144,29 +196,33 @@ window.LoadToAgentTerminalWorkbench = function createModule(context) {
144
196
  const canInput = Boolean((remote && !remote.pane.dead) || (session && session.status === 'running'));
145
197
  const closeButton = $('#terminalCloseBtn');
146
198
  closeButton.disabled = !hasTarget;
147
- closeButton.textContent = remote && !session ? '선택 해제' : window.LoadToAgentI18n.t("ui.end_session");
199
+ closeButton.textContent = remote && !session ? t('terminal.clear_selection') : t('ui.end_session');
148
200
  closeButton.classList.toggle('terminal-danger-button', Boolean(session));
149
201
  $('#terminalRestartBtn').classList.toggle('hidden', !session || session.status === 'running' || session.type === 'agent');
150
202
  $('#terminalRestartBtn').disabled = !session || session.status === 'running';
151
203
  $('#terminalCommandInput').disabled = !canInput;
152
204
  const commandForm = $('#terminalCommandForm');
153
- const commandButton = commandForm.querySelector('button');
205
+ const commandButton = commandForm.querySelector('button[type="submit"]');
154
206
  commandButton.disabled = !canInput || state.commandSending;
207
+ commandButton.toggleAttribute('aria-busy', state.commandSending);
155
208
  commandForm.toggleAttribute('aria-busy', state.commandSending);
156
209
  const commandButtonLabel = commandButton.querySelector('span');
157
- if (commandButtonLabel) commandButtonLabel.textContent = state.commandSending ? '보내는 중' : window.LoadToAgentI18n.t("common.send");
210
+ if (commandButtonLabel) commandButtonLabel.textContent = state.commandSending ? t('terminal.sending') : t('common.send');
158
211
  document.querySelectorAll('[data-terminal-signal]').forEach(button => { button.disabled = !canInput; });
159
212
  $('#terminalAttachBtn').classList.toggle('hidden', !remote || Boolean(session));
160
213
  $('#terminalTmuxTools').classList.toggle('hidden', !remote || Boolean(session));
161
214
  if (session) {
162
- setConnectionState(STATUS_LABELS[session.status] || session.status, session.status);
215
+ const presentation = terminalPresentation(session);
216
+ setConnectionState(presentation.label, presentation.tone);
163
217
  $('#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>`;
218
+ $('#terminalTargetMeta').innerHTML = `<b>${esc(session.title)}</b><span>${bound ? `● ${t('terminal.bound_ai_session')} · ` : ''}${esc(session.type.toUpperCase())} · PID ${session.pid || '--'} · ${esc(session.cwd || session.distro || '')}</span>`;
165
219
  $('#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;
220
+ $('#terminalConsoleState').textContent = presentation.tone === 'attention' || presentation.tone === 'completed'
221
+ ? presentation.label
222
+ : canInput ? window.LoadToAgentI18n.t("ui.direct_input_available") : window.LoadToAgentI18n.t("ui.ended_session");
223
+ $('#terminalConsoleState').dataset.status = presentation.tone;
168
224
  } else if (remote) {
169
- setConnectionState(remote.pane.dead ? '종료된 tmux' : 'tmux 연결됨', remote.pane.dead ? 'exited' : 'running');
225
+ setConnectionState(remote.pane.dead ? t('terminal.tmux.ended_pane') : t('terminal.tmux.connected'), remote.pane.dead ? 'exited' : 'running');
170
226
  $('#terminalTargetIcon').textContent = 'tm';
171
227
  $('#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
228
  $('#terminalConsoleCaption').textContent = `${remote.window.index}:${remote.window.name} · ${remote.pane.command || 'shell'}`;
@@ -176,7 +232,7 @@ window.LoadToAgentTerminalWorkbench = function createModule(context) {
176
232
  setConnectionState(window.LoadToAgentI18n.t("ui.waiting_for_selection"));
177
233
  $('#terminalTargetIcon').textContent = '›_';
178
234
  $('#terminalTargetMeta').innerHTML = state.mode === 'tmux'
179
- ? '<b>아직 선택한 tmux 명령창이 없습니다</b><span>왼쪽 tmux 목록이나 위 지도에서 조작할 칸을 선택하세요.</span>'
235
+ ? `<b>${t('terminal.tmux.no_selection_title')}</b><span>${t('terminal.tmux.no_selection_description')}</span>`
180
236
  : `<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
237
  $('#terminalConsoleCaption').textContent = window.LoadToAgentI18n.t("ui.select_a_session_to_show_its_output_here");
182
238
  $('#terminalConsoleState').textContent = window.LoadToAgentI18n.t("ui.waiting_for_selection");
@@ -187,25 +243,40 @@ window.LoadToAgentTerminalWorkbench = function createModule(context) {
187
243
  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
244
  if (commandInput) commandInput.placeholder = !hasTarget
189
245
  ? window.LoadToAgentI18n.t("ui.select_a_session_on_the_left_first")
190
- : (bound ? '이전 대화에 이어서 AI에게 지시할 내용을 입력하세요' : window.LoadToAgentI18n.t("ui.enter_a_command_to_run"));
246
+ : (bound ? t('terminal.command.continue_ai_placeholder') : t('ui.enter_a_command_to_run'));
247
+ if (commandInput) {
248
+ $('#terminalCommandCount').textContent = `${commandInput.value.length.toLocaleString()} / 8,000`;
249
+ $('#terminalCommandClearBtn')?.classList.toggle('hidden', !commandInput.value);
250
+ }
191
251
  renderHistoryPanel();
192
252
  }
193
253
 
194
254
  async function showSelection() {
255
+ const generation = state.captureGeneration;
256
+ const expectedMode = state.mode;
257
+ const expectedSessionId = state.selectedId;
258
+ const expectedTmuxId = state.selectedTmux?.pane?.id || state.selectedTmux?.pane?.nativeId || '';
259
+ const selectionIsCurrent = () => generation === state.captureGeneration
260
+ && expectedMode === state.mode
261
+ && expectedSessionId === state.selectedId
262
+ && expectedTmuxId === (state.selectedTmux?.pane?.id || state.selectedTmux?.pane?.nativeId || '');
195
263
  hideScreens();
196
264
  const session = currentSession();
197
265
  const remote = currentTmux();
198
266
  if (session) {
199
267
  const entry = await ensureSessionTerminal(session);
268
+ if (!selectionIsCurrent()) return;
200
269
  entry.host.classList.remove('hidden');
201
270
  fitEntry(entry, session.id);
202
271
  stopCapture();
203
272
  } else if (remote) {
273
+ if (!selectionIsCurrent()) return;
204
274
  const entry = ensureRemoteTerminal();
205
275
  entry.host.classList.remove('hidden');
206
276
  fitEntry(entry);
207
277
  startCapture();
208
278
  } else {
279
+ if (!selectionIsCurrent()) return;
209
280
  $('#terminalEmpty').classList.remove('hidden');
210
281
  stopCapture();
211
282
  }
@@ -214,20 +285,22 @@ window.LoadToAgentTerminalWorkbench = function createModule(context) {
214
285
 
215
286
  async function selectSession(id) {
216
287
  saveCurrentDraft();
217
- state.captureGeneration += 1;
288
+ const generation = ++state.captureGeneration;
218
289
  state.selectedId = id;
219
290
  state.selectedTmux = null;
220
291
  renderSessions();
221
292
  renderTmuxResources();
222
293
  await showSelection();
294
+ if (!state.active || state.captureGeneration !== generation || state.selectedId !== id || state.mode !== 'general') return;
223
295
  restoreCurrentDraft();
296
+ if (!$('#terminalCommandInput')?.disabled) $('#terminalCommandInput').focus({ preventScroll: true });
224
297
  }
225
298
 
226
299
  async function selectTmux(distroName, paneId) {
227
300
  const row = tmuxRows().find(item => item.distro.name === distroName && item.pane.nativeId === paneId);
228
- if (!row) return notice('선택한 나눠진 명령창을 찾을 수 없습니다.', 'error');
301
+ if (!row) return notice(t('terminal.error.selected_split_missing'), 'error');
229
302
  saveCurrentDraft();
230
- state.captureGeneration += 1;
303
+ const generation = ++state.captureGeneration;
231
304
  state.selectedId = null;
232
305
  state.selectedTmux = row;
233
306
  state.remoteCapture = '';
@@ -237,12 +310,15 @@ window.LoadToAgentTerminalWorkbench = function createModule(context) {
237
310
  renderSessions();
238
311
  renderTmuxResources();
239
312
  await showSelection();
313
+ if (!state.active || state.captureGeneration !== generation || state.selectedId || state.mode !== 'tmux'
314
+ || state.selectedTmux?.distro?.name !== distroName || state.selectedTmux?.pane?.nativeId !== paneId) return;
240
315
  restoreCurrentDraft();
316
+ if (!$('#terminalCommandInput')?.disabled) $('#terminalCommandInput').focus({ preventScroll: true });
241
317
  }
242
318
 
243
319
  async function selectTmuxById(paneId) {
244
320
  const row = tmuxRows().find(item => item.pane.id === paneId || item.pane.nativeId === paneId);
245
- if (!row) return notice('선택한 tmux 명령창을 찾을 수 없습니다.', 'error');
321
+ if (!row) return notice(t('terminal.error.selected_tmux_missing'), 'error');
246
322
  state.mode = 'tmux';
247
323
  moveWorkbench('tmux');
248
324
  return selectTmux(row.distro.name, row.pane.nativeId);
@@ -272,15 +348,15 @@ window.LoadToAgentTerminalWorkbench = function createModule(context) {
272
348
 
273
349
  async function createTerminal(type) {
274
350
  const distro = type === 'wsl' ? firstDistro() : null;
275
- if (type === 'wsl' && !distro) return notice('사용 가능한 Linux 환경이 없습니다.', 'error');
351
+ if (type === 'wsl' && !distro) return notice(t('terminal.error.no_linux_environment'), 'error');
276
352
  const created = await guarded(() => window.loadtoagent.terminalCreate({
277
353
  type,
278
354
  cwd: (type === 'powershell' || type === 'shell') ? (preferredWorkspace() || undefined) : undefined,
279
355
  distro: distro && distro.name,
280
- title: type === 'powershell' ? 'PowerShell' : (type === 'shell' ? state.platform.localShellLabel : `${distro.name} 셸`),
356
+ title: type === 'powershell' ? 'PowerShell' : (type === 'shell' ? state.platform.localShellLabel : t('terminal.linux_shell_title', { distro: distro.name })),
281
357
  cols: 120,
282
358
  rows: 32,
283
- }), `${type === 'powershell' ? 'Windows' : (type === 'shell' ? state.platform.label : 'Linux')} 명령창을 열었습니다.`);
359
+ }), t('terminal.opened', { platform: type === 'powershell' ? 'Windows' : (type === 'shell' ? state.platform.label : 'Linux') }), `terminal-create:${type}`);
284
360
  if (!created) return;
285
361
  await refreshSessions();
286
362
  await selectSession(created.id);
@@ -308,6 +384,7 @@ window.LoadToAgentTerminalWorkbench = function createModule(context) {
308
384
  const wasAtBottom = state.remoteViewportAnchor == null
309
385
  ? Boolean(buffer && buffer.viewportY >= buffer.baseY)
310
386
  : state.remoteViewportAtBottom;
387
+ state.remoteCaptureApplying = true;
311
388
  entry.terminal.reset();
312
389
  await new Promise(resolve => entry.terminal.write(result.output.replace(/\n/g, '\r\n'), resolve));
313
390
  const selected = currentTmux();
@@ -317,19 +394,26 @@ window.LoadToAgentTerminalWorkbench = function createModule(context) {
317
394
  setTimeout(captureRemote, 0);
318
395
  return;
319
396
  }
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
- });
397
+ await new Promise(resolve => requestAnimationFrame(() => {
398
+ try {
399
+ const latest = currentTmux();
400
+ if (captureGeneration !== state.captureGeneration || !latest || `${latest.distro.name}:${latest.pane.nativeId}` !== captureKey) return;
401
+ if (firstCapture) entry.terminal.scrollToTop();
402
+ else if (state.remoteViewportAnchor == null ? wasAtBottom : state.remoteViewportAtBottom) entry.terminal.scrollToBottom();
403
+ else entry.terminal.scrollToLine(state.remoteViewportAnchor == null ? previousViewport : state.remoteViewportAnchor);
404
+ const restoredBuffer = entry.terminal.buffer.active;
405
+ state.remoteViewportAnchor = Number(restoredBuffer.viewportY) || 0;
406
+ state.remoteViewportAtBottom = !firstCapture && state.remoteViewportAnchor >= Number(restoredBuffer.baseY || 0);
407
+ state.captureRevision += 1;
408
+ entry.host.dataset.captureRevision = String(state.captureRevision);
409
+ } catch (error) {
410
+ window.LoadToAgentRendererUtils.reportRecoverableError('tmux-capture-render', error);
411
+ } finally {
412
+ resolve();
413
+ }
414
+ }));
332
415
  } finally {
416
+ state.remoteCaptureApplying = false;
333
417
  state.captureInFlight = false;
334
418
  }
335
419
  }
@@ -349,21 +433,21 @@ window.LoadToAgentTerminalWorkbench = function createModule(context) {
349
433
  const text = String(command || '');
350
434
  if (state.commandSending) return false;
351
435
  if (!text.trim()) {
352
- notice('보낼 명령을 입력하세요.', 'error');
436
+ notice(t('terminal.command.required'), 'error');
353
437
  return false;
354
438
  }
355
439
  const session = currentSession();
356
440
  const remote = currentTmux();
357
441
  if (!session && !remote) {
358
- notice('사용할 명령창을 먼저 선택하세요.', 'error');
442
+ notice(t('terminal.command.select_first'), 'error');
359
443
  return false;
360
444
  }
361
445
  state.commandSending = true;
362
446
  renderTarget();
363
447
  try {
364
448
  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 }), '선택한 나눠진 명령창에서 실행했습니다.');
449
+ ? await guarded(() => window.loadtoagent.terminalCommand(session.id, text), t('terminal.command.sent'))
450
+ : await guarded(() => window.loadtoagent.tmuxSendText({ distro: remote.distro.name, target: remote.pane.nativeId, text, enter: true }), t('terminal.command.executed_in_split'));
367
451
  if (result && remote) setTimeout(captureRemote, 160);
368
452
  return Boolean(result);
369
453
  } finally {
@@ -375,12 +459,12 @@ window.LoadToAgentTerminalWorkbench = function createModule(context) {
375
459
  async function sendSignal(signal) {
376
460
  const session = currentSession();
377
461
  const remote = currentTmux();
378
- if (session) return guarded(() => window.loadtoagent.terminalSignal(session.id, signal), signal === 'interrupt' ? 'Ctrl+C를 보냈습니다.' : '화면을 정리했습니다.');
462
+ if (session) return guarded(() => window.loadtoagent.terminalSignal(session.id, signal), signal === 'interrupt' ? t('terminal.signal.interrupt_sent') : t('terminal.signal.cleared'));
379
463
  if (remote) {
380
464
  const key = signal === 'interrupt' ? 'C-c' : 'C-l';
381
- return guarded(() => window.loadtoagent.tmuxSendKey({ distro: remote.distro.name, target: remote.pane.nativeId, key }), `${key}를 보냈습니다.`);
465
+ return guarded(() => window.loadtoagent.tmuxSendKey({ distro: remote.distro.name, target: remote.pane.nativeId, key }), t('terminal.signal.key_sent', { key }));
382
466
  }
383
- notice('사용할 명령창을 먼저 선택하세요.', 'error');
467
+ notice(t('terminal.command.select_first'), 'error');
384
468
  }
385
469
 
386
470
  function openTmuxModal() {
@@ -388,18 +472,22 @@ window.LoadToAgentTerminalWorkbench = function createModule(context) {
388
472
  const distros = state.snapshot && state.snapshot.tmux && state.snapshot.tmux.distros || [];
389
473
  $('#tmuxCreateDistro').innerHTML = distros.map(item => `<option value="${esc(item.name)}">${esc(item.name)}</option>`).join('');
390
474
  $('#tmuxCreateError').classList.add('hidden');
475
+ window.LoadToAgentA11y?.setDialogOpenState($('#tmuxCreateModal'), true);
391
476
  $('#tmuxCreateModal').classList.remove('hidden');
392
477
  $('#tmuxCreateName').focus();
393
478
  }
394
479
 
395
- function closeTmuxModal() {
480
+ function closeTmuxModal(force = false) {
481
+ if (force !== true && $('#tmuxCreateForm [type="submit"]').dataset.busy === 'true') return;
396
482
  $('#tmuxCreateModal').classList.add('hidden');
483
+ window.LoadToAgentA11y?.setDialogOpenState($('#tmuxCreateModal'), false);
397
484
  $('#tmuxCreateForm').reset();
485
+ $('#tmuxCreateForm').querySelectorAll('[aria-invalid="true"]').forEach(element => element.removeAttribute('aria-invalid'));
398
486
  window.LoadToAgentA11y?.restoreDialogTrigger();
399
487
  }
400
488
 
401
489
  async function refreshSnapshot() {
402
- const snapshot = await guarded(() => window.loadtoagent.snapshot(), '여러 창 작업을 새로고침했습니다.');
490
+ const snapshot = await guarded(() => window.loadtoagent.snapshot(), t('terminal.tmux.refreshed'), 'tmux-refresh');
403
491
  if (snapshot) updateSnapshot(snapshot, state.workspaces);
404
492
  }
405
493
 
@@ -414,7 +502,7 @@ window.LoadToAgentTerminalWorkbench = function createModule(context) {
414
502
  title: `tmux · ${remote.session.name} · ${remote.pane.nativeId}`,
415
503
  cols: 120,
416
504
  rows: 32,
417
- }), '키보드로 직접 조작할 수 있게 연결했습니다.');
505
+ }), t('terminal.tmux.attached_for_input'), `tmux-attach:${remote.distro.name}:${remote.pane.nativeId}`);
418
506
  if (!created) return;
419
507
  await refreshSessions();
420
508
  await selectSession(created.id);
@@ -427,33 +515,33 @@ window.LoadToAgentTerminalWorkbench = function createModule(context) {
427
515
  let operation = null;
428
516
  let message = '';
429
517
  if (action === 'rename-session') {
430
- const name = window.prompt('새 작업 묶음 이름', remote.session.name);
518
+ const name = window.prompt(t('terminal.tmux.prompt_workspace_name'), remote.session.name);
431
519
  if (!name || name === remote.session.name) return;
432
520
  operation = () => window.loadtoagent.tmuxRenameSession({ ...base, target: remote.session.nativeId, name });
433
- message = '작업 묶음 이름을 변경했습니다.';
521
+ message = t('terminal.tmux.workspace_renamed');
434
522
  } else if (action === 'new-window') {
435
- const name = window.prompt('새 창 이름', 'window');
523
+ const name = window.prompt(t('terminal.tmux.prompt_window_name'), 'window');
436
524
  if (!name) return;
437
525
  operation = () => window.loadtoagent.tmuxNewWindow({ ...base, target: remote.session.nativeId, name, cwd: remote.pane.cwd });
438
- message = '새 창을 만들었습니다.';
526
+ message = t('terminal.tmux.window_created');
439
527
  } else if (action === 'split-horizontal' || action === 'split-vertical') {
440
528
  operation = () => window.loadtoagent.tmuxSplitPane({ ...base, target: remote.pane.nativeId, direction: action === 'split-horizontal' ? 'horizontal' : 'vertical', cwd: remote.pane.cwd });
441
- message = '선택한 명령창을 나눴습니다.';
529
+ message = t('terminal.tmux.pane_split');
442
530
  } else if (action === 'kill-pane') {
443
- if (!window.confirm(`${remote.pane.nativeId} 명령창을 닫을까요?`)) return;
531
+ if (!window.confirm(t('terminal.tmux.confirm_close_pane', { pane: remote.pane.nativeId }))) return;
444
532
  operation = () => window.loadtoagent.tmuxKillPane({ ...base, target: remote.pane.nativeId });
445
- message = '선택한 명령창을 닫았습니다.';
533
+ message = t('terminal.tmux.pane_closed');
446
534
  } else if (action === 'kill-window') {
447
- if (!window.confirm(`${remote.window.index}:${remote.window.name} 창과 안에 있는 명령창을 모두 닫을까요?`)) return;
535
+ if (!window.confirm(t('terminal.tmux.confirm_close_window', { window: `${remote.window.index}:${remote.window.name}` }))) return;
448
536
  operation = () => window.loadtoagent.tmuxKillWindow({ ...base, target: remote.window.nativeId });
449
- message = '창 전체를 닫았습니다.';
537
+ message = t('terminal.tmux.window_closed');
450
538
  } else if (action === 'kill-session') {
451
- if (!window.confirm(`${remote.session.name} 작업 묶음을 모두 끝낼까요?`)) return;
539
+ if (!window.confirm(t('terminal.tmux.confirm_end_workspace', { workspace: remote.session.name }))) return;
452
540
  operation = () => window.loadtoagent.tmuxKillSession({ ...base, target: remote.session.nativeId });
453
- message = '작업 묶음을 끝냈습니다.';
541
+ message = t('terminal.tmux.workspace_ended');
454
542
  }
455
543
  if (!operation) return;
456
- const result = await guarded(operation, message);
544
+ const result = await guarded(operation, message, `tmux-manage:${action}`);
457
545
  if (result) {
458
546
  if (action.startsWith('kill-')) {
459
547
  stopCapture();