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,7 +2,9 @@
2
2
 
3
3
  (() => {
4
4
  const { $, esc, uiLocale, providerLabel, reportRecoverableError } = window.LoadToAgentRendererUtils;
5
+ const t = (key, params) => window.LoadToAgentI18n.t(key, params);
5
6
  const SESSION_ORDER_KEY = 'loadtoagent:terminal-session-order:v1';
7
+ const TERMINAL_VIEW_KEY = 'loadtoagent:terminal-view:v1';
6
8
 
7
9
  function loadSessionOrder() {
8
10
  try {
@@ -14,6 +16,19 @@
14
16
  }
15
17
  }
16
18
 
19
+ function loadTerminalViewPreferences() {
20
+ try {
21
+ const saved = JSON.parse(localStorage.getItem(TERMINAL_VIEW_KEY) || '{}');
22
+ const fontSize = Math.min(20, Math.max(12, Number(saved.fontSize) || 15));
23
+ return { fontSize };
24
+ } catch (error) {
25
+ reportRecoverableError('terminal-view-load', error);
26
+ return { fontSize: 15 };
27
+ }
28
+ }
29
+
30
+ const terminalViewPreferences = loadTerminalViewPreferences();
31
+
17
32
  const state = {
18
33
  sessions: [],
19
34
  selectedId: null,
@@ -27,6 +42,7 @@
27
42
  remoteCapture: '',
28
43
  remoteViewportAnchor: null,
29
44
  remoteViewportAtBottom: false,
45
+ remoteCaptureApplying: false,
30
46
  captureTimer: null,
31
47
  captureInFlight: false,
32
48
  captureGeneration: 0,
@@ -41,20 +57,37 @@
41
57
  historyCollapsed: false,
42
58
  historyRefreshTimer: null,
43
59
  historyRequests: new Map(),
60
+ historyRenderKey: '',
61
+ historyPointerActive: false,
62
+ historyUserRevision: 0,
63
+ historyFollowFrame: 0,
64
+ historyRenderPending: false,
65
+ historyFlushFrame: 0,
44
66
  commandDrafts: new Map(),
67
+ commandHistory: new Map(),
68
+ commandHistoryNavigation: { targetId: '', index: -1, draft: '' },
45
69
  commandSending: false,
70
+ pendingActions: new Set(),
46
71
  sessionOrder: loadSessionOrder(),
72
+ sessionRenderKey: '',
47
73
  draggedSessionId: '',
48
74
  sessionDragJustEnded: false,
49
- platform: { id: 'win32', label: 'Windows', localShell: 'powershell', localShellLabel: 'Windows 명령창', nativeTmux: false },
75
+ terminalFontSize: terminalViewPreferences.fontSize,
76
+ terminalFocusMode: false,
77
+ platform: { id: 'win32', label: 'Windows', localShell: 'powershell', localShellLabel: 'Windows Terminal', nativeTmux: false },
50
78
  };
51
79
 
52
- const STATUS_LABELS = {
53
- starting: window.LoadToAgentI18n.t("ui.preparing"),
54
- running: '세션 유지 중',
55
- exited: '끝남',
56
- failed: '열지 못함',
57
- };
80
+ const STATUS_LABELS = {};
81
+
82
+ function refreshStatusLabels() {
83
+ Object.assign(STATUS_LABELS, {
84
+ starting: t('ui.preparing'),
85
+ running: t('terminal.status.running'),
86
+ exited: t('terminal.status.exited'),
87
+ failed: t('terminal.status.failed'),
88
+ });
89
+ }
90
+ refreshStatusLabels();
58
91
 
59
92
  function notice(message, tone = '') {
60
93
  const element = $('#terminalNotice');
@@ -64,7 +97,7 @@
64
97
  }
65
98
 
66
99
  function errorMessage(error) {
67
- return error && error.message ? error.message : String(error || '알 수 없는 오류');
100
+ return window.LoadToAgentI18n.errorText(error, 'terminal.error.unknown');
68
101
  }
69
102
 
70
103
  function persistSessionOrder() {
@@ -134,12 +167,12 @@
134
167
  }
135
168
 
136
169
  function resumeSupport(agentSession) {
137
- if (!agentSession) return { supported: false, reason: '세션 정보가 없습니다.' };
170
+ if (!agentSession) return { supported: false, reason: t('terminal.resume.no_session_info') };
138
171
  const sessionId = String(agentSession.externalId || '').trim();
139
- if (!sessionId) return { supported: false, reason: '재개에 필요한 세션 ID가 기록되지 않았습니다.' };
172
+ if (!sessionId) return { supported: false, reason: t('terminal.resume.no_session_id') };
140
173
  const provider = String(agentSession.provider || '').toLowerCase();
141
174
  if (!['codex', 'claude', 'gemini'].includes(provider)) {
142
- return { supported: false, reason: `${providerLabel(provider)} CLI의 세션 ID 재개 방식이 공식 문서에서 확인되지 않았습니다.` };
175
+ return { supported: false, reason: t('terminal.resume.unsupported_provider', { provider: providerLabel(provider) }) };
143
176
  }
144
177
  const args = provider === 'codex' ? ['resume', sessionId] : ['--resume', sessionId];
145
178
  return { supported: true, provider, sessionId, args };
@@ -153,13 +186,13 @@
153
186
  }
154
187
 
155
188
  function terminalTypeLabel(session) {
156
- if (!session) return '터미널';
189
+ if (!session) return t('terminal.type.terminal');
157
190
  if (session.type === 'wsl') return session.distro || 'Linux';
158
191
  if (session.type === 'agent') return providerLabel(session.provider);
159
192
  if (session.type === 'powershell') return 'PowerShell';
160
- if (session.type === 'cmd') return '명령 프롬프트';
193
+ if (session.type === 'cmd') return t('terminal.type.command_prompt');
161
194
  if (session.type === 'shell') return session.shell || 'Shell';
162
- return String(session.type || '터미널').toUpperCase();
195
+ return String(session.type || t('terminal.type.terminal')).toUpperCase();
163
196
  }
164
197
 
165
198
  function terminalTypeMark(session) {
@@ -179,6 +212,58 @@
179
212
  element.dataset.status = status;
180
213
  }
181
214
 
215
+ function persistTerminalViewPreferences() {
216
+ try {
217
+ localStorage.setItem(TERMINAL_VIEW_KEY, JSON.stringify({ fontSize: state.terminalFontSize }));
218
+ } catch (error) {
219
+ reportRecoverableError('terminal-view-save', error);
220
+ }
221
+ }
222
+
223
+ function syncTerminalViewControls() {
224
+ const section = $('#terminalSection');
225
+ const focusButton = $('#terminalFocusBtn');
226
+ section?.classList.toggle('terminal-focus-mode', state.terminalFocusMode);
227
+ $('#terminalResourcePanel')?.toggleAttribute('inert', state.terminalFocusMode);
228
+ $('#terminalHistoryPanel')?.toggleAttribute('inert', state.terminalFocusMode);
229
+ if ($('#terminalFontSizeLabel')) $('#terminalFontSizeLabel').textContent = `${state.terminalFontSize}px`;
230
+ if ($('#terminalFontDecreaseBtn')) $('#terminalFontDecreaseBtn').disabled = state.terminalFontSize <= 12;
231
+ if ($('#terminalFontIncreaseBtn')) $('#terminalFontIncreaseBtn').disabled = state.terminalFontSize >= 20;
232
+ if (focusButton) {
233
+ focusButton.setAttribute('aria-pressed', state.terminalFocusMode ? 'true' : 'false');
234
+ focusButton.title = state.terminalFocusMode ? t('terminal.view.focus_exit') : t('terminal.view.focus');
235
+ const label = focusButton.querySelector('span');
236
+ if (label) label.textContent = state.terminalFocusMode ? t('terminal.view.focus_exit') : t('terminal.view.focus');
237
+ }
238
+ }
239
+
240
+ function refitVisibleTerminal() {
241
+ const session = currentSession();
242
+ const entry = session ? state.terminals.get(session.id) : state.remoteTerminal;
243
+ fitEntry(entry, session?.id || '');
244
+ }
245
+
246
+ function setTerminalFontSize(value) {
247
+ const next = Math.min(20, Math.max(12, Math.round(Number(value) || 15)));
248
+ if (next === state.terminalFontSize) return;
249
+ state.terminalFontSize = next;
250
+ for (const entry of [...state.terminals.values(), state.remoteTerminal].filter(Boolean)) {
251
+ entry.terminal.options.fontSize = next;
252
+ entry.terminal.options.lineHeight = next >= 17 ? 1.32 : 1.28;
253
+ }
254
+ persistTerminalViewPreferences();
255
+ syncTerminalViewControls();
256
+ refitVisibleTerminal();
257
+ window.LoadToAgentA11y?.announce(t('terminal.view.font_size_value', { size: next }));
258
+ }
259
+
260
+ function toggleTerminalFocusMode() {
261
+ state.terminalFocusMode = !state.terminalFocusMode;
262
+ syncTerminalViewControls();
263
+ requestAnimationFrame(refitVisibleTerminal);
264
+ window.LoadToAgentA11y?.announce(state.terminalFocusMode ? t('terminal.view.focus_on') : t('terminal.view.focus_off'));
265
+ }
266
+
182
267
  function currentTargetId() {
183
268
  const session = currentSession();
184
269
  if (session) return session.id;
@@ -187,7 +272,9 @@
187
272
  }
188
273
 
189
274
  function visibleBoundAgent() {
190
- return state.boundAgent && state.boundTargetId === currentTargetId() ? state.boundAgent : null;
275
+ if (!state.boundAgent || state.boundTargetId !== currentTargetId()) return null;
276
+ if (window.LoadToAgentApp?.isProviderVisible?.(state.boundAgent.provider) === false) return null;
277
+ return state.boundAgent;
191
278
  }
192
279
 
193
280
  function saveCurrentDraft() {
@@ -198,7 +285,144 @@
198
285
 
199
286
  function restoreCurrentDraft() {
200
287
  const input = $('#terminalCommandInput');
201
- if (input) input.value = state.commandDrafts.get(currentTargetId()) || '';
288
+ if (input) {
289
+ input.value = state.commandDrafts.get(currentTargetId()) || '';
290
+ $('#terminalCommandClearBtn')?.classList.toggle('hidden', !input.value);
291
+ }
292
+ }
293
+
294
+ function observedProgressText(value) {
295
+ const observed = window.LoadToAgentI18n.observedText
296
+ ? window.LoadToAgentI18n.observedText(value)
297
+ : String(value || '');
298
+ const readable = window.LoadToAgentApp?.readableActivityDetail?.(observed);
299
+ return String(readable || observed || '').replace(/\s+/g, ' ').trim();
300
+ }
301
+
302
+ function terminalSubagentRecords(root) {
303
+ const sessions = Array.isArray(state.snapshot?.sessions) ? state.snapshot.sessions : [];
304
+ const byId = new Map(sessions.map(session => [session.id, session]));
305
+ // A detail refresh can contain newer root collaboration events than the card
306
+ // in the snapshot, so keep it as the authoritative root record.
307
+ byId.set(root.id, root);
308
+ const records = [];
309
+ const seen = new Set([root.id]);
310
+ const visit = (parent, depth) => {
311
+ const declaredIds = Array.isArray(parent?.childIds) ? parent.childIds : [];
312
+ const inferredIds = sessions
313
+ .filter(session => session?.parentId === parent?.id)
314
+ .map(session => session.id);
315
+ for (const id of [...new Set([...declaredIds, ...inferredIds])]) {
316
+ if (!id || seen.has(id)) continue;
317
+ const session = byId.get(id);
318
+ if (!session) continue;
319
+ seen.add(id);
320
+ records.push({ session, parent, depth });
321
+ visit(session, depth + 1);
322
+ }
323
+ };
324
+ visit(root, 1);
325
+
326
+ return records.map(record => {
327
+ const { session, parent, depth } = record;
328
+ const taskName = session.taskName || session.delegation?.taskName || session.title || '';
329
+ const communications = Array.isArray(parent?.collaboration?.communications)
330
+ ? parent.collaboration.communications
331
+ : [];
332
+ const related = communications
333
+ .filter(event => event && !event.protected)
334
+ .filter(event => ['assignment', 'started', 'followup', 'message', 'result', 'interrupt'].includes(event.kind))
335
+ .filter(event => event.childId === session.id || (taskName && event.taskName === taskName));
336
+ const assignmentEvent = [...related].reverse().find(event => event.kind === 'assignment' && observedProgressText(event.text));
337
+ const delegation = session.delegation || {};
338
+ const assignment = delegation.assignmentObserved && !delegation.assignmentProtected && observedProgressText(delegation.assignment)
339
+ ? observedProgressText(delegation.assignment)
340
+ : (assignmentEvent ? observedProgressText(assignmentEvent.text) : observedProgressText(session.title || taskName));
341
+ const lifecycle = (Array.isArray(session.lifecycle) ? session.lifecycle : [])
342
+ .filter(item => item && (item.label || item.detail))
343
+ .map((item, index) => ({
344
+ id: item.id || `lifecycle:${index}`,
345
+ kind: item.type || 'activity',
346
+ label: observedProgressText(item.label || t('ui.progress')),
347
+ detail: observedProgressText(item.detail),
348
+ timestamp: item.timestamp || session.updatedAt || '',
349
+ }));
350
+ const coordination = related
351
+ .filter(event => event.kind !== 'assignment')
352
+ .map(event => ({
353
+ id: event.id || `${event.kind}:${event.timestamp || ''}`,
354
+ kind: event.kind,
355
+ label: observedProgressText(event.label || event.kind),
356
+ detail: event.kind === 'started' && String(event.text || '').trim().toLowerCase() === 'started'
357
+ ? ''
358
+ : observedProgressText(event.text),
359
+ timestamp: event.timestamp || session.updatedAt || '',
360
+ }));
361
+ const events = [...lifecycle, ...coordination]
362
+ .sort((left, right) => (Date.parse(left.timestamp || 0) || 0) - (Date.parse(right.timestamp || 0) || 0))
363
+ .filter((event, index, items) => index === items.findIndex(candidate => (
364
+ candidate.kind === event.kind
365
+ && candidate.label === event.label
366
+ && candidate.detail === event.detail
367
+ && candidate.timestamp === event.timestamp
368
+ )))
369
+ .slice(-4);
370
+ const workState = window.LoadToAgentApp?.subagentWorkState?.(session)
371
+ || (session.status === 'running' || session.status === 'starting' ? 'working' : (session.status === 'failed' ? 'attention' : 'resting'));
372
+ const stateLabel = window.LoadToAgentApp?.subagentWorkLabel?.(session)
373
+ || ({ working: t('ui.working'), attention: t('ui.needs_attention'), resting: t('ui.idle') })[workState];
374
+ return {
375
+ session,
376
+ depth,
377
+ taskName,
378
+ assignment,
379
+ events,
380
+ workState,
381
+ stateLabel,
382
+ name: observedProgressText(session.agentName || taskName || session.title || providerLabel(session.provider)),
383
+ role: observedProgressText(session.agentRole || providerLabel(session.provider)),
384
+ statusDetail: observedProgressText(session.statusDetail),
385
+ };
386
+ });
387
+ }
388
+
389
+ function terminalSubagentProgress(root) {
390
+ const records = terminalSubagentRecords(root);
391
+ if (!records.length) return { html: '', key: [], count: 0, working: 0, attention: 0 };
392
+ const working = records.filter(record => record.workState === 'working').length;
393
+ const attention = records.filter(record => record.workState === 'attention').length;
394
+ const summary = [
395
+ t('tmux.subagents.connected_count', { count: records.length }),
396
+ working ? t('tmux.subagents.working_count', { count: working }) : t('tmux.subagents.all_idle'),
397
+ attention ? t('tmux.subagents.attention_count', { count: attention }) : '',
398
+ ].filter(Boolean).join(' · ');
399
+ const cards = records.map(record => {
400
+ const events = record.events.length
401
+ ? record.events.map(event => `<li data-terminal-subagent-event="${esc(event.kind)}">
402
+ <i></i><div><b>${esc(event.label || t('ui.progress'))}</b>${event.detail ? `<span>${esc(event.detail)}</span>` : ''}</div><time>${esc(timeLabel(event.timestamp))}</time>
403
+ </li>`).join('')
404
+ : `<li class="empty"><i></i><span>${esc(t('terminal.subagents.no_updates'))}</span></li>`;
405
+ return `<article class="terminal-subagent-card ${esc(record.workState)}" data-terminal-subagent-id="${esc(record.session.id)}" data-terminal-subagent-state="${esc(record.workState)}" data-terminal-subagent-depth="${record.depth}" style="--terminal-subagent-depth:${Math.min(record.depth - 1, 3)}">
406
+ <header><div><span>${record.depth > 1 ? '↳ ' : ''}${esc(record.name)}</span><small>${esc(record.role)}</small></div><b>${esc(record.stateLabel)}</b></header>
407
+ <div class="terminal-subagent-assignment"><em>${esc(t('drawer.assignment'))}</em><span>${esc(record.assignment || t('tmux.subagents.checking_assignment'))}</span></div>
408
+ ${record.statusDetail ? `<div class="terminal-subagent-current"><em>${esc(t('terminal.subagents.current'))}</em><span>${esc(record.statusDetail)}</span></div>` : ''}
409
+ <ol>${events}</ol>
410
+ </article>`;
411
+ }).join('');
412
+ return {
413
+ html: `<section class="terminal-subagent-progress" data-terminal-subagent-progress data-terminal-subagent-count="${records.length}">
414
+ <header><div><b>${esc(t('terminal.subagents.title'))}</b><span>${esc(t('terminal.subagents.description'))}</span></div><small>${esc(summary)}</small></header>
415
+ <div class="terminal-subagent-cards">${cards}</div>
416
+ </section>`,
417
+ key: records.map(record => [
418
+ record.session.id, record.session.parentId || '', record.depth, record.workState,
419
+ record.name, record.role, record.assignment, record.statusDetail,
420
+ record.events.map(event => [event.id, event.kind, event.label, event.detail, event.timestamp]),
421
+ ]),
422
+ count: records.length,
423
+ working,
424
+ attention,
425
+ };
202
426
  }
203
427
 
204
428
  function renderHistoryPanel(forceBottom = false) {
@@ -212,28 +436,72 @@
212
436
  if (toggle) {
213
437
  toggle.setAttribute('aria-expanded', state.historyCollapsed ? 'false' : 'true');
214
438
  toggle.textContent = state.historyCollapsed ? '›' : '‹';
215
- toggle.title = state.historyCollapsed ? '대화 영역 펼치기' : window.LoadToAgentI18n.t("ui.collapse_conversation_panel");
439
+ toggle.title = state.historyCollapsed ? t('terminal.history.expand') : t('ui.collapse_conversation_panel');
216
440
  }
217
441
  if (!agent) return;
218
442
  const allMessages = Array.isArray(agent.messages) ? agent.messages.filter(message => message && message.text) : [];
219
443
  const messages = allMessages.filter(message => message.role === 'user' || message.role === 'assistant');
220
444
  const activityCount = allMessages.length - messages.length;
221
445
  const shown = messages.slice(-80);
222
- $('#terminalHistoryTitle').textContent = agent.title || `${providerLabel(agent.provider)} 세션`;
446
+ const subagents = terminalSubagentProgress(agent);
447
+ $('#terminalHistoryTitle').textContent = agent.title || t('terminal.history.provider_session', { provider: providerLabel(agent.provider) });
223
448
  $('#terminalHistoryMeta').textContent = [
224
449
  providerLabel(agent.provider),
225
450
  window.LoadToAgentI18n.t('session.messages', { count: messages.length }),
451
+ subagents.count ? t('tmux.subagents.connected_count', { count: subagents.count }) : '',
226
452
  activityCount ? window.LoadToAgentI18n.t('terminal.activity_details', { count: activityCount }) : '',
227
453
  messages.length > shown.length ? window.LoadToAgentI18n.t('session.latest_count', { count: shown.length }) : '',
228
454
  ].filter(Boolean).join(' · ');
229
455
  const list = $('#terminalHistoryList');
230
- const nearBottom = list.scrollHeight - list.scrollTop - list.clientHeight < 90;
231
- list.innerHTML = shown.length ? shown.map(message => {
232
- const role = message.role === 'assistant' ? 'assistant' : (message.role === 'tool' ? 'tool' : (message.role === 'system' ? 'system' : 'user'));
233
- const label = role === 'assistant' ? providerLabel(agent.provider) : (role === 'tool' ? (message.title || '도구') : (role === 'system' ? '시스템' : '나'));
234
- return `<article class="terminal-history-message ${role}"><header><b>${esc(label)}</b><time>${esc(timeLabel(message.timestamp))}</time></header>${historyMessageHtml(message.text)}</article>`;
235
- }).join('') : '<div class="terminal-history-empty"><b>아직 표시할 대화가 없습니다</b><span>터미널 출력은 오른쪽에 그대로 유지됩니다.</span></div>';
236
- if (forceBottom || nearBottom) requestAnimationFrame(() => { list.scrollTop = list.scrollHeight; });
456
+ const previousTop = list.scrollTop;
457
+ const wasAtBottom = window.LoadToAgentRendererUtils.isScrolledToEnd(list);
458
+ const renderKey = JSON.stringify([
459
+ agent.id,
460
+ uiLocale(),
461
+ providerLabel(agent.provider),
462
+ subagents.key,
463
+ shown.map(message => [message.id || '', message.role || '', message.title || '', message.timestamp || '', message.text || '']),
464
+ ]);
465
+ const contentChanged = state.historyRenderKey !== renderKey;
466
+ const selection = window.getSelection?.();
467
+ const selectionInside = Boolean(selection && !selection.isCollapsed && (
468
+ (selection.anchorNode && list.contains(selection.anchorNode))
469
+ || (selection.focusNode && list.contains(selection.focusNode))
470
+ ));
471
+ if (contentChanged && !forceBottom && (state.historyPointerActive || selectionInside)) {
472
+ state.historyRenderPending = true;
473
+ return;
474
+ }
475
+ state.historyRenderPending = false;
476
+ if (contentChanged) {
477
+ const conversationHtml = shown.length ? shown.map(message => {
478
+ const role = message.role === 'assistant' ? 'assistant' : (message.role === 'tool' ? 'tool' : (message.role === 'system' ? 'system' : 'user'));
479
+ const label = role === 'assistant' ? providerLabel(agent.provider) : (role === 'tool' ? (message.title || t('terminal.history.tool')) : (role === 'system' ? t('terminal.history.system') : t('terminal.history.me')));
480
+ return `<article class="terminal-history-message ${role}"><header><b>${esc(label)}</b><time>${esc(timeLabel(message.timestamp))}</time></header>${historyMessageHtml(message.text)}</article>`;
481
+ }).join('') : `<div class="terminal-history-empty${subagents.count ? ' with-subagents' : ''}"><b>${t('terminal.history.empty_title')}</b><span>${t('terminal.history.empty_description')}</span></div>`;
482
+ list.innerHTML = `${subagents.html}${conversationHtml}`;
483
+ state.historyRenderKey = renderKey;
484
+ }
485
+
486
+ const shouldFollow = forceBottom || (contentChanged && wasAtBottom && !state.historyPointerActive && !selectionInside);
487
+ if (state.historyFollowFrame) cancelAnimationFrame(state.historyFollowFrame);
488
+ state.historyFollowFrame = 0;
489
+ if (!contentChanged && !forceBottom) return;
490
+ if (!shouldFollow) {
491
+ list.scrollTop = Math.min(previousTop, Math.max(0, list.scrollHeight - list.clientHeight));
492
+ return;
493
+ }
494
+ const revision = state.historyUserRevision;
495
+ state.historyFollowFrame = requestAnimationFrame(() => {
496
+ state.historyFollowFrame = 0;
497
+ if (revision !== state.historyUserRevision || state.historyPointerActive) return;
498
+ const activeSelection = window.getSelection?.();
499
+ if (activeSelection && !activeSelection.isCollapsed && (
500
+ (activeSelection.anchorNode && list.contains(activeSelection.anchorNode))
501
+ || (activeSelection.focusNode && list.contains(activeSelection.focusNode))
502
+ )) return;
503
+ list.scrollTop = list.scrollHeight;
504
+ });
237
505
  }
238
506
 
239
507
  function bindAgent(agentSession, target) {
@@ -271,7 +539,9 @@
271
539
  }, 240);
272
540
  }
273
541
 
274
- async function guarded(action, successMessage = '') {
542
+ async function guarded(action, successMessage = '', actionKey = '') {
543
+ if (actionKey && state.pendingActions.has(actionKey)) return null;
544
+ if (actionKey) state.pendingActions.add(actionKey);
275
545
  try {
276
546
  const result = await action();
277
547
  if (successMessage) notice(successMessage, 'success');
@@ -279,6 +549,8 @@
279
549
  } catch (error) {
280
550
  notice(errorMessage(error), 'error');
281
551
  return null;
552
+ } finally {
553
+ if (actionKey) state.pendingActions.delete(actionKey);
282
554
  }
283
555
  }
284
556
 
@@ -303,6 +575,8 @@
303
575
  function modeSessions(mode = state.mode) {
304
576
  const rank = new Map(normalizedSessionOrder().map((id, index) => [id, index]));
305
577
  return state.sessions
578
+ .filter(Boolean)
579
+ .filter(session => session.type !== 'agent' || window.LoadToAgentApp?.isProviderVisible?.(session.provider) !== false)
306
580
  .filter(session => mode === 'tmux' ? session.type === 'tmux' : session.type !== 'tmux')
307
581
  .sort((left, right) => (rank.get(left.id) ?? Number.MAX_SAFE_INTEGER) - (rank.get(right.id) ?? Number.MAX_SAFE_INTEGER));
308
582
  }
@@ -322,14 +596,14 @@
322
596
  function configurePlatform() {
323
597
  const localButton = $('#newPowerShellBtn');
324
598
  const linuxButton = $('#newWslBtn');
325
- if (localButton) localButton.textContent = `+ ${state.platform.localShellLabel.replace('명령창', '세션')}`;
599
+ if (localButton) localButton.textContent = t('terminal.new_local_session', { platform: state.platform.label });
326
600
  if (linuxButton) linuxButton.classList.toggle('hidden', state.platform.id !== 'win32');
327
601
  const explain = $('#terminalPlatformExplain');
328
602
  if (explain) explain.textContent = state.platform.id === 'win32'
329
- ? '기존 Windows·WSL 터미널 세션을 유지한 채 같은 화면에서 계속 입력합니다. AI 카드에서 열면 이전 대화도 함께 표시됩니다.'
330
- : `기존 ${state.platform.label} 터미널 세션을 유지한 채 계속 입력합니다. AI 카드에서 열면 이전 대화도 함께 표시됩니다.`;
603
+ ? t('terminal.platform.windows_description')
604
+ : t('terminal.platform.description', { platform: state.platform.label });
331
605
  const environmentLabel = $('#tmuxEnvironmentLabel');
332
- if (environmentLabel) environmentLabel.textContent = state.platform.nativeTmux ? '로컬 tmux 환경' : 'WSL 환경';
606
+ if (environmentLabel) environmentLabel.textContent = state.platform.nativeTmux ? t('terminal.environment.local_tmux') : t('terminal.environment.wsl');
333
607
  }
334
608
 
335
609
  function xtermOptions(readOnly = false) {
@@ -341,8 +615,8 @@
341
615
  convertEol: readOnly,
342
616
  screenReaderMode: true,
343
617
  fontFamily: '"Cascadia Code", Consolas, monospace',
344
- fontSize: 13,
345
- lineHeight: 1.22,
618
+ fontSize: state.terminalFontSize,
619
+ lineHeight: state.terminalFontSize >= 17 ? 1.32 : 1.28,
346
620
  scrollback: 10_000,
347
621
  theme: {
348
622
  background: '#080c12',
@@ -359,7 +633,7 @@
359
633
  }
360
634
 
361
635
  const {
362
- 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,
636
+ 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,
363
637
  } = window.LoadToAgentTerminalWorkbench({
364
638
  $, state, notice, setConnectionState, currentSession, currentTmux, saveCurrentDraft, restoreCurrentDraft,
365
639
  renderHistoryPanel, terminalTypeMark, terminalTypeLabel, xtermOptions, preferredWorkspace, firstDistro, guarded,
@@ -402,6 +676,9 @@
402
676
  notice,
403
677
  reorderSession,
404
678
  moveSessionByOffset,
679
+ setTerminalFontSize,
680
+ toggleTerminalFocusMode,
681
+ isAiTerminalSession,
405
682
  });
406
683
  }
407
684
 
@@ -435,13 +712,25 @@
435
712
  }
436
713
 
437
714
  function updateSnapshot(snapshot, workspaces = state.workspaces) {
438
- state.snapshot = snapshot || state.snapshot;
715
+ const projected = snapshot && window.LoadToAgentApp?.projectVisibleSnapshot
716
+ ? window.LoadToAgentApp.projectVisibleSnapshot(snapshot)
717
+ : snapshot;
718
+ state.snapshot = projected || state.snapshot;
439
719
  state.workspaces = Array.isArray(workspaces) ? workspaces : state.workspaces;
440
720
  if (!state.initialized) return;
441
721
  if (state.boundAgent && state.snapshot && Array.isArray(state.snapshot.sessions)) {
442
722
  const updated = state.snapshot.sessions.find(session => session.id === state.boundAgent.id);
723
+ // Keep the conversation associated with its live terminal even when a
724
+ // monitor refresh temporarily omits an ended or slow-to-scan AI session.
725
+ // The binding is explicitly cleared when the terminal closes, and the
726
+ // provider visibility guard above prevents hidden providers from leaking.
443
727
  if (updated && updated.updatedAt !== state.boundAgent.updatedAt) queueHistoryRefresh(updated);
728
+ // Child sessions are updated independently from their parent. The render
729
+ // key prevents unnecessary DOM replacement while still reflecting their
730
+ // latest lifecycle and collaboration events in the terminal history.
731
+ renderHistoryPanel();
444
732
  }
733
+ renderSessions();
445
734
  renderTmuxResources();
446
735
  renderTarget();
447
736
  if (state.active && state.selectedTmux) startCapture();
@@ -457,6 +746,20 @@
457
746
  return true;
458
747
  }
459
748
 
749
+ function scrollTmuxByLines(lines) {
750
+ if (!state.remoteTerminal) return false;
751
+ state.remoteTerminal.terminal.scrollLines(Math.trunc(Number(lines) || 0));
752
+ return true;
753
+ }
754
+
755
+ function scrollTerminalToLine(id, line) {
756
+ const entry = state.terminals.get(String(id || ''));
757
+ if (!entry) return false;
758
+ entry.userScrollRevision += 1;
759
+ entry.terminal.scrollToLine(Math.max(0, Math.floor(Number(line) || 0)));
760
+ return true;
761
+ }
762
+
460
763
  function init() {
461
764
  if (state.initPromise) return state.initPromise;
462
765
  state.initPromise = (async () => {
@@ -471,6 +774,14 @@
471
774
  state.wslDistros = Array.isArray(environments) ? environments : [];
472
775
  state.initialized = true;
473
776
  configurePlatform();
777
+ if (!state.resizeObserver && 'ResizeObserver' in window) {
778
+ state.resizeObserver = new ResizeObserver(() => {
779
+ const entry = currentSession() ? state.terminals.get(state.selectedId) : state.remoteTerminal;
780
+ fitEntry(entry, state.selectedId || '');
781
+ });
782
+ state.resizeObserver.observe($('#terminalViewport'));
783
+ }
784
+ syncTerminalViewControls();
474
785
  renderAll();
475
786
  })().catch(error => {
476
787
  state.initialized = false;
@@ -493,11 +804,15 @@
493
804
  openForAgent,
494
805
  resumeForAgent,
495
806
  scrollTmuxToLine,
807
+ scrollTmuxByLines,
808
+ scrollTerminalToLine,
496
809
  };
497
810
  window.addEventListener('loadtoagent:locale-changed', () => {
811
+ refreshStatusLabels();
498
812
  if (!state.initialized) return;
499
813
  configurePlatform();
814
+ syncTerminalViewControls();
500
815
  renderAll();
501
816
  });
502
- init().catch(error => notice(`명령창 준비 실패: ${errorMessage(error)}`, 'error'));
817
+ init().catch(error => notice(t('terminal.error.initialization_failed', { message: errorMessage(error) }), 'error'));
503
818
  })();