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
  /** 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,48 +19,60 @@ 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 = {
23
+ terminal, fit, host, readOnly, userScrollRevision: 0, outputWritePending: 0,
24
+ writeQueue: Promise.resolve(), pendingResize: null, resizePromise: null,
25
+ };
21
26
  const syncScrollState = viewportY => {
22
- host.dataset.viewportY = String(Number(viewportY) || 0);
23
- host.dataset.baseY = String(Number(terminal.buffer.active.baseY) || 0);
27
+ const normalizedViewport = Number(viewportY) || 0;
28
+ const baseY = Number(terminal.buffer.active.baseY) || 0;
29
+ host.dataset.viewportY = String(normalizedViewport);
30
+ host.dataset.baseY = String(baseY);
31
+ // Xterm may consume wheel events before they bubble to the host. Its
32
+ // scroll event is the reliable source for mouse, keyboard and scrollbar
33
+ // viewport changes.
34
+ if (readOnly && !state.remoteCaptureApplying) {
35
+ state.remoteViewportAnchor = normalizedViewport;
36
+ state.remoteViewportAtBottom = normalizedViewport >= baseY;
37
+ }
24
38
  };
25
39
  terminal.onScroll(syncScrollState);
26
40
  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
41
  if (!readOnly) {
42
+ const rememberUserScroll = () => { entry.userScrollRevision += 1; };
43
+ host.addEventListener('wheel', rememberUserScroll, { capture: true, passive: true });
44
+ host.addEventListener('pointerup', rememberUserScroll, true);
45
+ host.addEventListener('keyup', event => {
46
+ if (['PageUp', 'PageDown', 'Home', 'End', 'ArrowUp', 'ArrowDown'].includes(event.key)) rememberUserScroll();
47
+ }, true);
41
48
  terminal.onData(data => {
42
- if (state.selectedId === key) Promise.resolve(window.loadtoagent.terminalWrite(key, data)).catch(error => notice(errorMessage(error), 'error'));
49
+ if (state.selectedId !== key) return;
50
+ entry.writeQueue = entry.writeQueue
51
+ .then(() => window.loadtoagent.terminalWrite(key, data))
52
+ .catch(error => notice(errorMessage(error), 'error'));
43
53
  });
44
54
  terminal.onResize(size => {
45
- Promise.resolve(window.loadtoagent.terminalResize(key, size.cols, size.rows)).catch(error => {
55
+ entry.pendingResize = { cols: size.cols, rows: size.rows };
56
+ if (entry.resizePromise) return;
57
+ entry.resizePromise = (async () => {
58
+ while (entry.pendingResize) {
59
+ const pending = entry.pendingResize;
60
+ entry.pendingResize = null;
61
+ await window.loadtoagent.terminalResize(key, pending.cols, pending.rows);
62
+ }
63
+ })().catch(error => {
46
64
  window.LoadToAgentRendererUtils.reportRecoverableError('terminal-resize', error);
47
- });
65
+ }).finally(() => { entry.resizePromise = null; });
48
66
  });
49
67
  }
50
68
  return entry;
51
69
  }
52
70
 
53
- function fitEntry(entry, sessionId = '') {
71
+ function fitEntry(entry, _sessionId = '') {
54
72
  if (!entry || entry.host.classList.contains('hidden')) return;
55
73
  requestAnimationFrame(() => {
56
74
  try {
57
75
  entry.fit.fit();
58
- if (sessionId) {
59
- Promise.resolve(window.loadtoagent.terminalResize(sessionId, entry.terminal.cols, entry.terminal.rows)).catch(error => {
60
- window.LoadToAgentRendererUtils.reportRecoverableError('terminal-fit-resize', error);
61
- });
62
- }
63
76
  } catch (error) {
64
77
  window.LoadToAgentRendererUtils.reportRecoverableError('terminal-fit', error);
65
78
  }
@@ -88,49 +101,110 @@ window.LoadToAgentTerminalWorkbench = function createModule(context) {
88
101
  $('#terminalEmpty').classList.add('hidden');
89
102
  }
90
103
 
104
+ function linkedAgentSession(session) {
105
+ if (!session) return null;
106
+ if (state.boundTargetId === session.id && state.boundAgent) return state.boundAgent;
107
+ const agents = Array.isArray(state.snapshot?.sessions) ? state.snapshot.sessions : [];
108
+ const bridgeId = String(session.bridgeId || '');
109
+ const bridged = bridgeId ? agents.find(item => item.id === bridgeId) : null;
110
+ if (bridged) return bridged;
111
+ const terminalPid = Number(session.pid || 0);
112
+ return agents.find(agent => (Array.isArray(agent.runtimePresence) ? agent.runtimePresence : []).some(item => (
113
+ item.terminalId === session.id
114
+ || (terminalPid > 0 && Number(item.pid || 0) === terminalPid)
115
+ || (terminalPid > 0 && Number(item.parentPid || 0) === terminalPid)
116
+ ))) || null;
117
+ }
118
+
119
+ function isAiTerminalSession(session) {
120
+ return Boolean(session && (session.type === 'agent' || linkedAgentSession(session)));
121
+ }
122
+
123
+ function terminalPresentation(session) {
124
+ const agent = linkedAgentSession(session);
125
+ if (agent?.attention?.required || agent?.status === 'waiting') return { tone: 'attention', label: t('ui.waiting_for_review') };
126
+ if (agent?.status === 'failed' || session?.status === 'failed') return { tone: 'failed', label: t('terminal.status.failed') };
127
+ if (agent?.status === 'completed') return { tone: 'completed', label: t('ui.completed') };
128
+ if (agent && ['running', 'starting'].includes(agent.status)) return { tone: 'running', label: t('ui.working') };
129
+ if (session?.status === 'running' || session?.status === 'starting') return { tone: 'running', label: STATUS_LABELS[session.status] || session.status };
130
+ return { tone: session?.status || 'idle', label: STATUS_LABELS[session?.status] || session?.status || t('ui.idle') };
131
+ }
132
+
91
133
  function renderSessions() {
92
134
  const general = modeSessions('general');
93
135
  const running = general.filter(item => item.status === 'running').length;
94
136
  const background = general.filter(item => item.background && item.status === 'running').length;
137
+ const attention = general.filter(item => terminalPresentation(item).tone === 'attention').length;
95
138
  $('#navTerminalCount').textContent = running;
139
+ const terminalNav = document.querySelector('.nav-item[data-view="terminal"]');
140
+ if (terminalNav) terminalNav.setAttribute('aria-label', t('quality.nav_count_detailed', { label: t('app.nav.session_terminal'), count: running, unit: t('quality.unit.sessions') }));
141
+ const advancedCount = ['navRuntimeCount', 'navTerminalCount', 'navTmuxCount']
142
+ .reduce((total, id) => total + Number(document.getElementById(id)?.textContent || 0), 0);
143
+ const advancedCounter = document.getElementById('advancedToolsCount');
144
+ if (advancedCounter) advancedCounter.textContent = String(advancedCount);
145
+ document.querySelector('#advancedToolsNav > summary')?.setAttribute('aria-label', t('quality.nav_count_detailed', {
146
+ label: t('management.advanced_tools'), count: advancedCount, unit: t('quality.unit.items'),
147
+ }));
96
148
  $('#terminalSessionSummary').textContent = [
97
149
  window.LoadToAgentI18n.t('common.active', { count: running }),
150
+ attention ? t('terminal.monitor.attention_count', { count: attention }) : '',
98
151
  background ? window.LoadToAgentI18n.t('session.background_count', { count: background }) : '',
99
152
  window.LoadToAgentI18n.t('session.total_count', { count: general.length }),
100
153
  ].filter(Boolean).join(' · ');
101
- $('#terminalSessionList').innerHTML = general.length ? general.map((session, index) => `
154
+ const renderKey = JSON.stringify([
155
+ state.selectedId,
156
+ general.map(session => {
157
+ const presentation = terminalPresentation(session);
158
+ return [
159
+ session.id, session.title, session.type, session.status, session.pid, session.cwd, session.background,
160
+ session.recoveredAfterHostRestart, session.recoverySkippedReason, presentation.tone, presentation.label,
161
+ ];
162
+ }),
163
+ ]);
164
+ if (renderKey === state.sessionRenderKey) return;
165
+ state.sessionRenderKey = renderKey;
166
+ $('#terminalSessionList').innerHTML = general.length ? general.map((session, index) => {
167
+ const presentation = terminalPresentation(session);
168
+ return `
102
169
  <div class="terminal-session-row">
103
170
  <button type="button" draggable="true"
104
171
  class="terminal-session-item ${state.selectedId === session.id ? 'active' : ''}"
172
+ data-status="${esc(presentation.tone)}"
105
173
  data-terminal-id="${esc(session.id)}"
174
+ role="option"
175
+ aria-selected="${state.selectedId === session.id ? 'true' : 'false'}"
176
+ tabindex="${state.selectedId === session.id || (!state.selectedId && index === 0) ? '0' : '-1'}"
177
+ aria-pressed="${state.selectedId === session.id ? 'true' : 'false'}"
106
178
  aria-grabbed="false"
107
179
  aria-describedby="terminalReorderHelp"
108
- title="${esc(window.LoadToAgentI18n.t('terminal.reorder_hint'))}">
180
+ title="${esc(session.cwd || window.LoadToAgentI18n.t('terminal.reorder_hint'))}">
109
181
  <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>
182
+ <span><b>${esc(session.title)}</b><small>${esc(terminalTypeLabel(session))}${session.background ? ` · ${t('terminal.background_kept')}` : ''}${session.recoveredAfterHostRestart ? ` · ${t('terminal.recovered_after_host_restart')}` : ''}</small><em>${esc(session.cwd || session.distro || `PID ${session.pid || '--'}`)}</em><span class="sr-only">${index + 1}/${general.length}</span></span>
183
+ <span class="terminal-session-status" data-status="${esc(presentation.tone)}"><i></i>${esc(presentation.label)}</span>
112
184
  </button>
113
185
  <span class="terminal-reorder-actions" aria-label="${esc(window.LoadToAgentI18n.t('terminal.reorder_group', { title: session.title }))}">
114
186
  <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
187
  <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
188
  </span>
117
- </div>`).join('') : '<div class="terminal-resource-empty">열어 둔 일반 명령창이 없습니다.</div>';
189
+ </div>`;
190
+ }).join('') : `<div class="terminal-resource-empty">${t('terminal.empty.general')}</div>`;
118
191
  }
119
192
 
120
193
  function renderTmuxResources() {
121
194
  const distros = state.snapshot && state.snapshot.tmux && state.snapshot.tmux.distros || [];
122
195
  if (!distros.length) {
123
- $('#terminalTmuxList').innerHTML = '<div class="terminal-resource-empty">Linux에서 나눠 실행 중인 작업이 없습니다.</div>';
196
+ $('#terminalTmuxList').innerHTML = `<div class="terminal-resource-empty">${t('terminal.empty.tmux')}</div>`;
124
197
  return;
125
198
  }
199
+ let paneIndex = 0;
126
200
  $('#terminalTmuxList').innerHTML = distros.map(distro => `
127
201
  <section class="terminal-tmux-group">
128
- <header><b>${esc(distro.name)}</b><span>작업 묶음 ${(distro.sessions || []).length}개</span></header>
202
+ <header><b>${esc(distro.name)}</b><span>${t('terminal.tmux.workspace_count', { count: (distro.sessions || []).length })}</span></header>
129
203
  ${(distro.sessions || []).map(session => `
130
- <div class="terminal-tmux-session"><strong>${esc(session.name)}</strong><small>${session.attached ? '화면에 연결됨' : '뒤에서 실행 중'}</small></div>
204
+ <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
205
  ${(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>
206
+ <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++}">
207
+ <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
208
  <i class="${pane.agent ? 'agent' : (pane.active ? 'live' : '')}">${pane.agent ? 'AI' : (pane.active ? 'ON' : '')}</i>
135
209
  </button>`)).join('')}`).join('')}
136
210
  </section>`).join('');
@@ -140,33 +214,45 @@ window.LoadToAgentTerminalWorkbench = function createModule(context) {
140
214
  const session = currentSession();
141
215
  const remote = currentTmux();
142
216
  const bound = visibleBoundAgent();
217
+ const aiTerminal = isAiTerminalSession(session);
143
218
  const hasTarget = Boolean(session || remote);
144
219
  const canInput = Boolean((remote && !remote.pane.dead) || (session && session.status === 'running'));
145
220
  const closeButton = $('#terminalCloseBtn');
146
221
  closeButton.disabled = !hasTarget;
147
- closeButton.textContent = remote && !session ? '선택 해제' : window.LoadToAgentI18n.t("ui.end_session");
148
- closeButton.classList.toggle('terminal-danger-button', Boolean(session));
222
+ closeButton.textContent = remote && !session
223
+ ? t('terminal.clear_selection')
224
+ : session?.type === 'tmux'
225
+ ? t('terminal.detach_tmux_input')
226
+ : aiTerminal ? t('terminal.close_view') : t('ui.end_session');
227
+ closeButton.classList.toggle('terminal-danger-button', Boolean(session && !aiTerminal && session.type !== 'tmux'));
228
+ const endSessionButton = $('#terminalEndSessionBtn');
229
+ endSessionButton.classList.toggle('hidden', !aiTerminal);
230
+ endSessionButton.disabled = !aiTerminal;
149
231
  $('#terminalRestartBtn').classList.toggle('hidden', !session || session.status === 'running' || session.type === 'agent');
150
232
  $('#terminalRestartBtn').disabled = !session || session.status === 'running';
151
233
  $('#terminalCommandInput').disabled = !canInput;
152
234
  const commandForm = $('#terminalCommandForm');
153
- const commandButton = commandForm.querySelector('button');
235
+ const commandButton = commandForm.querySelector('button[type="submit"]');
154
236
  commandButton.disabled = !canInput || state.commandSending;
237
+ commandButton.toggleAttribute('aria-busy', state.commandSending);
155
238
  commandForm.toggleAttribute('aria-busy', state.commandSending);
156
239
  const commandButtonLabel = commandButton.querySelector('span');
157
- if (commandButtonLabel) commandButtonLabel.textContent = state.commandSending ? '보내는 중' : window.LoadToAgentI18n.t("common.send");
240
+ if (commandButtonLabel) commandButtonLabel.textContent = state.commandSending ? t('terminal.sending') : t('common.send');
158
241
  document.querySelectorAll('[data-terminal-signal]').forEach(button => { button.disabled = !canInput; });
159
242
  $('#terminalAttachBtn').classList.toggle('hidden', !remote || Boolean(session));
160
243
  $('#terminalTmuxTools').classList.toggle('hidden', !remote || Boolean(session));
161
244
  if (session) {
162
- setConnectionState(STATUS_LABELS[session.status] || session.status, session.status);
245
+ const presentation = terminalPresentation(session);
246
+ setConnectionState(presentation.label, presentation.tone);
163
247
  $('#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>`;
248
+ $('#terminalTargetMeta').innerHTML = `<b>${esc(session.title)}</b><span>${bound ? `● ${t('terminal.bound_ai_session')} · ` : ''}${session.recoveredAfterHostRestart ? `${t('terminal.recovered_after_host_restart')} · ` : ''}${esc(session.type.toUpperCase())} · PID ${session.pid || '--'} · ${esc(session.cwd || session.distro || '')}</span>`;
165
249
  $('#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;
250
+ $('#terminalConsoleState').textContent = presentation.tone === 'attention' || presentation.tone === 'completed'
251
+ ? presentation.label
252
+ : canInput ? window.LoadToAgentI18n.t("ui.direct_input_available") : window.LoadToAgentI18n.t("ui.ended_session");
253
+ $('#terminalConsoleState').dataset.status = presentation.tone;
168
254
  } else if (remote) {
169
- setConnectionState(remote.pane.dead ? '종료된 tmux' : 'tmux 연결됨', remote.pane.dead ? 'exited' : 'running');
255
+ setConnectionState(remote.pane.dead ? t('terminal.tmux.ended_pane') : t('terminal.tmux.connected'), remote.pane.dead ? 'exited' : 'running');
170
256
  $('#terminalTargetIcon').textContent = 'tm';
171
257
  $('#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
258
  $('#terminalConsoleCaption').textContent = `${remote.window.index}:${remote.window.name} · ${remote.pane.command || 'shell'}`;
@@ -176,7 +262,7 @@ window.LoadToAgentTerminalWorkbench = function createModule(context) {
176
262
  setConnectionState(window.LoadToAgentI18n.t("ui.waiting_for_selection"));
177
263
  $('#terminalTargetIcon').textContent = '›_';
178
264
  $('#terminalTargetMeta').innerHTML = state.mode === 'tmux'
179
- ? '<b>아직 선택한 tmux 명령창이 없습니다</b><span>왼쪽 tmux 목록이나 위 지도에서 조작할 칸을 선택하세요.</span>'
265
+ ? `<b>${t('terminal.tmux.no_selection_title')}</b><span>${t('terminal.tmux.no_selection_description')}</span>`
180
266
  : `<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
267
  $('#terminalConsoleCaption').textContent = window.LoadToAgentI18n.t("ui.select_a_session_to_show_its_output_here");
182
268
  $('#terminalConsoleState').textContent = window.LoadToAgentI18n.t("ui.waiting_for_selection");
@@ -187,25 +273,40 @@ window.LoadToAgentTerminalWorkbench = function createModule(context) {
187
273
  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
274
  if (commandInput) commandInput.placeholder = !hasTarget
189
275
  ? window.LoadToAgentI18n.t("ui.select_a_session_on_the_left_first")
190
- : (bound ? '이전 대화에 이어서 AI에게 지시할 내용을 입력하세요' : window.LoadToAgentI18n.t("ui.enter_a_command_to_run"));
276
+ : (bound ? t('terminal.command.continue_ai_placeholder') : t('ui.enter_a_command_to_run'));
277
+ if (commandInput) {
278
+ $('#terminalCommandCount').textContent = `${commandInput.value.length.toLocaleString()} / 8,000`;
279
+ $('#terminalCommandClearBtn')?.classList.toggle('hidden', !commandInput.value);
280
+ }
191
281
  renderHistoryPanel();
192
282
  }
193
283
 
194
284
  async function showSelection() {
285
+ const generation = state.captureGeneration;
286
+ const expectedMode = state.mode;
287
+ const expectedSessionId = state.selectedId;
288
+ const expectedTmuxId = state.selectedTmux?.pane?.id || state.selectedTmux?.pane?.nativeId || '';
289
+ const selectionIsCurrent = () => generation === state.captureGeneration
290
+ && expectedMode === state.mode
291
+ && expectedSessionId === state.selectedId
292
+ && expectedTmuxId === (state.selectedTmux?.pane?.id || state.selectedTmux?.pane?.nativeId || '');
195
293
  hideScreens();
196
294
  const session = currentSession();
197
295
  const remote = currentTmux();
198
296
  if (session) {
199
297
  const entry = await ensureSessionTerminal(session);
298
+ if (!selectionIsCurrent()) return;
200
299
  entry.host.classList.remove('hidden');
201
300
  fitEntry(entry, session.id);
202
301
  stopCapture();
203
302
  } else if (remote) {
303
+ if (!selectionIsCurrent()) return;
204
304
  const entry = ensureRemoteTerminal();
205
305
  entry.host.classList.remove('hidden');
206
306
  fitEntry(entry);
207
307
  startCapture();
208
308
  } else {
309
+ if (!selectionIsCurrent()) return;
209
310
  $('#terminalEmpty').classList.remove('hidden');
210
311
  stopCapture();
211
312
  }
@@ -214,20 +315,22 @@ window.LoadToAgentTerminalWorkbench = function createModule(context) {
214
315
 
215
316
  async function selectSession(id) {
216
317
  saveCurrentDraft();
217
- state.captureGeneration += 1;
318
+ const generation = ++state.captureGeneration;
218
319
  state.selectedId = id;
219
320
  state.selectedTmux = null;
220
321
  renderSessions();
221
322
  renderTmuxResources();
222
323
  await showSelection();
324
+ if (!state.active || state.captureGeneration !== generation || state.selectedId !== id || state.mode !== 'general') return;
223
325
  restoreCurrentDraft();
326
+ if (!$('#terminalCommandInput')?.disabled) $('#terminalCommandInput').focus({ preventScroll: true });
224
327
  }
225
328
 
226
329
  async function selectTmux(distroName, paneId) {
227
330
  const row = tmuxRows().find(item => item.distro.name === distroName && item.pane.nativeId === paneId);
228
- if (!row) return notice('선택한 나눠진 명령창을 찾을 수 없습니다.', 'error');
331
+ if (!row) return notice(t('terminal.error.selected_split_missing'), 'error');
229
332
  saveCurrentDraft();
230
- state.captureGeneration += 1;
333
+ const generation = ++state.captureGeneration;
231
334
  state.selectedId = null;
232
335
  state.selectedTmux = row;
233
336
  state.remoteCapture = '';
@@ -237,12 +340,15 @@ window.LoadToAgentTerminalWorkbench = function createModule(context) {
237
340
  renderSessions();
238
341
  renderTmuxResources();
239
342
  await showSelection();
343
+ if (!state.active || state.captureGeneration !== generation || state.selectedId || state.mode !== 'tmux'
344
+ || state.selectedTmux?.distro?.name !== distroName || state.selectedTmux?.pane?.nativeId !== paneId) return;
240
345
  restoreCurrentDraft();
346
+ if (!$('#terminalCommandInput')?.disabled) $('#terminalCommandInput').focus({ preventScroll: true });
241
347
  }
242
348
 
243
349
  async function selectTmuxById(paneId) {
244
350
  const row = tmuxRows().find(item => item.pane.id === paneId || item.pane.nativeId === paneId);
245
- if (!row) return notice('선택한 tmux 명령창을 찾을 수 없습니다.', 'error');
351
+ if (!row) return notice(t('terminal.error.selected_tmux_missing'), 'error');
246
352
  state.mode = 'tmux';
247
353
  moveWorkbench('tmux');
248
354
  return selectTmux(row.distro.name, row.pane.nativeId);
@@ -258,8 +364,9 @@ window.LoadToAgentTerminalWorkbench = function createModule(context) {
258
364
  const nextSessions = payload && Array.isArray(payload.sessions) ? payload.sessions : await window.loadtoagent.terminalList();
259
365
  state.sessions = Array.isArray(nextSessions) ? nextSessions : [];
260
366
  const activeIds = new Set(state.sessions.map(session => session.id));
367
+ const rehydratedIds = new Set(payload?.change === 'reconnected' ? activeIds : []);
261
368
  for (const [id, entry] of state.terminals) {
262
- if (activeIds.has(id)) continue;
369
+ if (activeIds.has(id) && !rehydratedIds.has(id)) continue;
263
370
  entry.terminal.dispose();
264
371
  entry.host.remove();
265
372
  state.terminals.delete(id);
@@ -272,15 +379,15 @@ window.LoadToAgentTerminalWorkbench = function createModule(context) {
272
379
 
273
380
  async function createTerminal(type) {
274
381
  const distro = type === 'wsl' ? firstDistro() : null;
275
- if (type === 'wsl' && !distro) return notice('사용 가능한 Linux 환경이 없습니다.', 'error');
382
+ if (type === 'wsl' && !distro) return notice(t('terminal.error.no_linux_environment'), 'error');
276
383
  const created = await guarded(() => window.loadtoagent.terminalCreate({
277
384
  type,
278
385
  cwd: (type === 'powershell' || type === 'shell') ? (preferredWorkspace() || undefined) : undefined,
279
386
  distro: distro && distro.name,
280
- title: type === 'powershell' ? 'PowerShell' : (type === 'shell' ? state.platform.localShellLabel : `${distro.name} 셸`),
387
+ title: type === 'powershell' ? 'PowerShell' : (type === 'shell' ? state.platform.localShellLabel : t('terminal.linux_shell_title', { distro: distro.name })),
281
388
  cols: 120,
282
389
  rows: 32,
283
- }), `${type === 'powershell' ? 'Windows' : (type === 'shell' ? state.platform.label : 'Linux')} 명령창을 열었습니다.`);
390
+ }), t('terminal.opened', { platform: type === 'powershell' ? 'Windows' : (type === 'shell' ? state.platform.label : 'Linux') }), `terminal-create:${type}`);
284
391
  if (!created) return;
285
392
  await refreshSessions();
286
393
  await selectSession(created.id);
@@ -308,6 +415,7 @@ window.LoadToAgentTerminalWorkbench = function createModule(context) {
308
415
  const wasAtBottom = state.remoteViewportAnchor == null
309
416
  ? Boolean(buffer && buffer.viewportY >= buffer.baseY)
310
417
  : state.remoteViewportAtBottom;
418
+ state.remoteCaptureApplying = true;
311
419
  entry.terminal.reset();
312
420
  await new Promise(resolve => entry.terminal.write(result.output.replace(/\n/g, '\r\n'), resolve));
313
421
  const selected = currentTmux();
@@ -317,19 +425,26 @@ window.LoadToAgentTerminalWorkbench = function createModule(context) {
317
425
  setTimeout(captureRemote, 0);
318
426
  return;
319
427
  }
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
- });
428
+ await new Promise(resolve => requestAnimationFrame(() => {
429
+ try {
430
+ const latest = currentTmux();
431
+ if (captureGeneration !== state.captureGeneration || !latest || `${latest.distro.name}:${latest.pane.nativeId}` !== captureKey) return;
432
+ if (firstCapture) entry.terminal.scrollToTop();
433
+ else if (state.remoteViewportAnchor == null ? wasAtBottom : state.remoteViewportAtBottom) entry.terminal.scrollToBottom();
434
+ else entry.terminal.scrollToLine(state.remoteViewportAnchor == null ? previousViewport : state.remoteViewportAnchor);
435
+ const restoredBuffer = entry.terminal.buffer.active;
436
+ state.remoteViewportAnchor = Number(restoredBuffer.viewportY) || 0;
437
+ state.remoteViewportAtBottom = !firstCapture && state.remoteViewportAnchor >= Number(restoredBuffer.baseY || 0);
438
+ state.captureRevision += 1;
439
+ entry.host.dataset.captureRevision = String(state.captureRevision);
440
+ } catch (error) {
441
+ window.LoadToAgentRendererUtils.reportRecoverableError('tmux-capture-render', error);
442
+ } finally {
443
+ resolve();
444
+ }
445
+ }));
332
446
  } finally {
447
+ state.remoteCaptureApplying = false;
333
448
  state.captureInFlight = false;
334
449
  }
335
450
  }
@@ -349,21 +464,21 @@ window.LoadToAgentTerminalWorkbench = function createModule(context) {
349
464
  const text = String(command || '');
350
465
  if (state.commandSending) return false;
351
466
  if (!text.trim()) {
352
- notice('보낼 명령을 입력하세요.', 'error');
467
+ notice(t('terminal.command.required'), 'error');
353
468
  return false;
354
469
  }
355
470
  const session = currentSession();
356
471
  const remote = currentTmux();
357
472
  if (!session && !remote) {
358
- notice('사용할 명령창을 먼저 선택하세요.', 'error');
473
+ notice(t('terminal.command.select_first'), 'error');
359
474
  return false;
360
475
  }
361
476
  state.commandSending = true;
362
477
  renderTarget();
363
478
  try {
364
479
  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 }), '선택한 나눠진 명령창에서 실행했습니다.');
480
+ ? await guarded(() => window.loadtoagent.terminalCommand(session.id, text), t('terminal.command.sent'))
481
+ : await guarded(() => window.loadtoagent.tmuxSendText({ distro: remote.distro.name, target: remote.pane.nativeId, text, enter: true }), t('terminal.command.executed_in_split'));
367
482
  if (result && remote) setTimeout(captureRemote, 160);
368
483
  return Boolean(result);
369
484
  } finally {
@@ -375,12 +490,12 @@ window.LoadToAgentTerminalWorkbench = function createModule(context) {
375
490
  async function sendSignal(signal) {
376
491
  const session = currentSession();
377
492
  const remote = currentTmux();
378
- if (session) return guarded(() => window.loadtoagent.terminalSignal(session.id, signal), signal === 'interrupt' ? 'Ctrl+C를 보냈습니다.' : '화면을 정리했습니다.');
493
+ if (session) return guarded(() => window.loadtoagent.terminalSignal(session.id, signal), signal === 'interrupt' ? t('terminal.signal.interrupt_sent') : t('terminal.signal.cleared'));
379
494
  if (remote) {
380
495
  const key = signal === 'interrupt' ? 'C-c' : 'C-l';
381
- return guarded(() => window.loadtoagent.tmuxSendKey({ distro: remote.distro.name, target: remote.pane.nativeId, key }), `${key}를 보냈습니다.`);
496
+ return guarded(() => window.loadtoagent.tmuxSendKey({ distro: remote.distro.name, target: remote.pane.nativeId, key }), t('terminal.signal.key_sent', { key }));
382
497
  }
383
- notice('사용할 명령창을 먼저 선택하세요.', 'error');
498
+ notice(t('terminal.command.select_first'), 'error');
384
499
  }
385
500
 
386
501
  function openTmuxModal() {
@@ -388,18 +503,22 @@ window.LoadToAgentTerminalWorkbench = function createModule(context) {
388
503
  const distros = state.snapshot && state.snapshot.tmux && state.snapshot.tmux.distros || [];
389
504
  $('#tmuxCreateDistro').innerHTML = distros.map(item => `<option value="${esc(item.name)}">${esc(item.name)}</option>`).join('');
390
505
  $('#tmuxCreateError').classList.add('hidden');
506
+ window.LoadToAgentA11y?.setDialogOpenState($('#tmuxCreateModal'), true);
391
507
  $('#tmuxCreateModal').classList.remove('hidden');
392
508
  $('#tmuxCreateName').focus();
393
509
  }
394
510
 
395
- function closeTmuxModal() {
511
+ function closeTmuxModal(force = false) {
512
+ if (force !== true && $('#tmuxCreateForm [type="submit"]').dataset.busy === 'true') return;
396
513
  $('#tmuxCreateModal').classList.add('hidden');
514
+ window.LoadToAgentA11y?.setDialogOpenState($('#tmuxCreateModal'), false);
397
515
  $('#tmuxCreateForm').reset();
516
+ $('#tmuxCreateForm').querySelectorAll('[aria-invalid="true"]').forEach(element => element.removeAttribute('aria-invalid'));
398
517
  window.LoadToAgentA11y?.restoreDialogTrigger();
399
518
  }
400
519
 
401
520
  async function refreshSnapshot() {
402
- const snapshot = await guarded(() => window.loadtoagent.snapshot(), '여러 창 작업을 새로고침했습니다.');
521
+ const snapshot = await guarded(() => window.loadtoagent.snapshot(), t('terminal.tmux.refreshed'), 'tmux-refresh');
403
522
  if (snapshot) updateSnapshot(snapshot, state.workspaces);
404
523
  }
405
524
 
@@ -414,7 +533,7 @@ window.LoadToAgentTerminalWorkbench = function createModule(context) {
414
533
  title: `tmux · ${remote.session.name} · ${remote.pane.nativeId}`,
415
534
  cols: 120,
416
535
  rows: 32,
417
- }), '키보드로 직접 조작할 수 있게 연결했습니다.');
536
+ }), t('terminal.tmux.attached_for_input'), `tmux-attach:${remote.distro.name}:${remote.pane.nativeId}`);
418
537
  if (!created) return;
419
538
  await refreshSessions();
420
539
  await selectSession(created.id);
@@ -427,33 +546,33 @@ window.LoadToAgentTerminalWorkbench = function createModule(context) {
427
546
  let operation = null;
428
547
  let message = '';
429
548
  if (action === 'rename-session') {
430
- const name = window.prompt('새 작업 묶음 이름', remote.session.name);
549
+ const name = window.prompt(t('terminal.tmux.prompt_workspace_name'), remote.session.name);
431
550
  if (!name || name === remote.session.name) return;
432
551
  operation = () => window.loadtoagent.tmuxRenameSession({ ...base, target: remote.session.nativeId, name });
433
- message = '작업 묶음 이름을 변경했습니다.';
552
+ message = t('terminal.tmux.workspace_renamed');
434
553
  } else if (action === 'new-window') {
435
- const name = window.prompt('새 창 이름', 'window');
554
+ const name = window.prompt(t('terminal.tmux.prompt_window_name'), 'window');
436
555
  if (!name) return;
437
556
  operation = () => window.loadtoagent.tmuxNewWindow({ ...base, target: remote.session.nativeId, name, cwd: remote.pane.cwd });
438
- message = '새 창을 만들었습니다.';
557
+ message = t('terminal.tmux.window_created');
439
558
  } else if (action === 'split-horizontal' || action === 'split-vertical') {
440
559
  operation = () => window.loadtoagent.tmuxSplitPane({ ...base, target: remote.pane.nativeId, direction: action === 'split-horizontal' ? 'horizontal' : 'vertical', cwd: remote.pane.cwd });
441
- message = '선택한 명령창을 나눴습니다.';
560
+ message = t('terminal.tmux.pane_split');
442
561
  } else if (action === 'kill-pane') {
443
- if (!window.confirm(`${remote.pane.nativeId} 명령창을 닫을까요?`)) return;
562
+ if (!window.confirm(t('terminal.tmux.confirm_close_pane', { pane: remote.pane.nativeId }))) return;
444
563
  operation = () => window.loadtoagent.tmuxKillPane({ ...base, target: remote.pane.nativeId });
445
- message = '선택한 명령창을 닫았습니다.';
564
+ message = t('terminal.tmux.pane_closed');
446
565
  } else if (action === 'kill-window') {
447
- if (!window.confirm(`${remote.window.index}:${remote.window.name} 창과 안에 있는 명령창을 모두 닫을까요?`)) return;
566
+ if (!window.confirm(t('terminal.tmux.confirm_close_window', { window: `${remote.window.index}:${remote.window.name}` }))) return;
448
567
  operation = () => window.loadtoagent.tmuxKillWindow({ ...base, target: remote.window.nativeId });
449
- message = '창 전체를 닫았습니다.';
568
+ message = t('terminal.tmux.window_closed');
450
569
  } else if (action === 'kill-session') {
451
- if (!window.confirm(`${remote.session.name} 작업 묶음을 모두 끝낼까요?`)) return;
570
+ if (!window.confirm(t('terminal.tmux.confirm_end_workspace', { workspace: remote.session.name }))) return;
452
571
  operation = () => window.loadtoagent.tmuxKillSession({ ...base, target: remote.session.nativeId });
453
- message = '작업 묶음을 끝냈습니다.';
572
+ message = t('terminal.tmux.workspace_ended');
454
573
  }
455
574
  if (!operation) return;
456
- const result = await guarded(operation, message);
575
+ const result = await guarded(operation, message, `tmux-manage:${action}`);
457
576
  if (result) {
458
577
  if (action.startsWith('kill-')) {
459
578
  stopCapture();
@@ -470,5 +589,5 @@ window.LoadToAgentTerminalWorkbench = function createModule(context) {
470
589
  }
471
590
  }
472
591
 
473
- return { createXtermHost, fitEntry, ensureSessionTerminal, ensureRemoteTerminal, hideScreens, renderSessions, renderTmuxResources, renderTarget, showSelection, selectSession, selectTmux, selectTmuxById, renderAll, refreshSessions, createTerminal, captureRemote, startCapture, stopCapture, sendCommand, sendSignal, openTmuxModal, closeTmuxModal, refreshSnapshot, attachTmux, manageTmux };
592
+ return { createXtermHost, fitEntry, ensureSessionTerminal, ensureRemoteTerminal, hideScreens, linkedAgentSession, isAiTerminalSession, renderSessions, renderTmuxResources, renderTarget, showSelection, selectSession, selectTmux, selectTmuxById, renderAll, refreshSessions, createTerminal, captureRemote, startCapture, stopCapture, sendCommand, sendSignal, openTmuxModal, closeTmuxModal, refreshSnapshot, attachTmux, manageTmux };
474
593
  };