// Suggestion Overlay 팝업 스크립트 // 입력 인텔리전스: 다음 문장 제안(ghost text)을 커서 옆에 보여준다. // // 상태 세 가지를 화면에 드러낸다: // warmingUp - 모델을 메모리에 올리는 중 (스피너 + "준비 중") // generating - 아직 후보가 하나도 없는 첫 생성 중 (스피너 + 도착한 부분 텍스트) // 후보 도착 - 한 페이지(3개)만 보여준다. 뒤로 더 채워지는 중이면(최대 12개) // 헤더 스피너 대신 진행률 줄에 작은 표시만 한다(중복 스피너 방지). // // 이 창은 focusable:false 다. 그래서 키 입력(수락/순환/페이지/닫기)은 전역 키바인딩이 // 메인에서 처리하고, 이 파일은 마우스 클릭만 처리한다. ;(function () { 'use strict' var PAGE_SIZE = 3 var candidatesContainer = document.getElementById('candidates') var provenanceContainer = document.getElementById('provenance') var progressContainer = document.getElementById('progress') var keyHintsContainer = document.getElementById('keyHints') var footer = document.getElementById('footer') var statusRow = document.getElementById('status') var statusText = document.getElementById('statusText') var panel = document.getElementById('panel') var closeButton = document.getElementById('close') /** @type {string[]} */ var candidates = [] var activeIndex = 0 var targetTotal = 0 var generating = false var warmingUp = false var partialText = null var provenance = null var i18nStrings = {} var keyHints = null var generatingSince = 0 var tickTimer = null function render() { candidatesContainer.textContent = '' // 후보가 이미 있으면(채우기 루프가 배경에서 더 만드는 중이어도) 헤더 스피너는 // 끈다 — 진행률 줄의 작은 표시가 그 역할을 대신한다(중복 표시 방지). var busy = warmingUp || (generating && candidates.length === 0) if (statusRow) statusRow.hidden = !busy if (statusText) { if (warmingUp) { statusText.textContent = i18nStrings.suggestionWarming || '...' } else if (busy) { // 경과 시간을 보여준다 — 모델이 바쁘면 몇 초 걸리는지 보이는 편이 덜 답답하다. var seconds = generatingSince ? Math.max(1, Math.round((Date.now() - generatingSince) / 1000)) : 1 var template = i18nStrings.suggestionGenerating || i18nStrings.suggestionLoading || '...' statusText.textContent = template.replace('{{seconds}}', String(seconds)) } else { statusText.textContent = '' } } if (footer) footer.hidden = candidates.length === 0 if (candidates.length === 0) { // 스트리밍 중이면 도착한 부분 텍스트를 그대로 보여준다 ("계속 생성되는" 느낌). if (partialText) { candidatesContainer.appendChild(buildItem('div', 'suggestion-item streaming', '1', partialText)) } return } var page = Math.floor(activeIndex / PAGE_SIZE) var pageStart = page * PAGE_SIZE var pageEnd = Math.min(pageStart + PAGE_SIZE, candidates.length) for (var i = pageStart; i < pageEnd; i++) { var item = buildItem( 'button', i === activeIndex ? 'suggestion-item active' : 'suggestion-item', String(i + 1), candidates[i] ) item.type = 'button' item.setAttribute('data-index', String(i)) item.addEventListener('click', onItemClick) candidatesContainer.appendChild(item) } renderProgress() renderKeyHints() renderProvenance() } function buildItem(tag, className, number, text) { var item = document.createElement(tag) item.className = className var num = document.createElement('span') num.className = 'suggestion-num' num.textContent = number var body = document.createElement('span') body.className = 'suggestion-text' body.textContent = text item.appendChild(num) item.appendChild(body) return item } function renderProgress() { if (!progressContainer) return progressContainer.textContent = '' var page = Math.floor(activeIndex / PAGE_SIZE) var from = page * PAGE_SIZE + 1 var to = Math.min((page + 1) * PAGE_SIZE, candidates.length) var range = document.createElement('span') range.textContent = from + '–' + to + ' / ' + candidates.length progressContainer.appendChild(range) if (generating) { var template = i18nStrings.suggestionHintGeneratingMore || i18nStrings.suggestionHintGenerating || '' var more = document.createElement('span') more.className = 'progress-more' var dot = document.createElement('span') dot.className = 'spinner' dot.setAttribute('aria-hidden', 'true') var label = document.createElement('span') label.textContent = template.replace('{{max}}', String(targetTotal || candidates.length)) more.appendChild(dot) more.appendChild(label) progressContainer.appendChild(more) } } function kbd(text) { var el = document.createElement('kbd') el.textContent = text return el } function renderKeyHints() { if (!keyHintsContainer) return keyHintsContainer.textContent = '' if (!keyHints) return // 이동·페이지·수락이 같은 수정자를 쓰면 앞에 한 번만 보여준다 ("Ctrl+Alt +"). if (keyHints.shared) { var shared = document.createElement('span') shared.className = 'key-hint shared' shared.appendChild(kbd(keyHints.shared)) shared.appendChild(document.createTextNode('+')) keyHintsContainer.appendChild(shared) } var entries = [ [keyHints.move, i18nStrings.suggestionHintMoveLabel, ''], [keyHints.page, i18nStrings.suggestionHintPageLabel, ''], [keyHints.accept, i18nStrings.suggestionHintAcceptLabel, ''], [keyHints.close, i18nStrings.suggestionHintCloseLabel, ' close-hint'] ] for (var i = 0; i < entries.length; i++) { // 바인딩이 없는 액션은 힌트 자체를 생략한다. if (!entries[i][0]) continue var hint = document.createElement('span') hint.className = 'key-hint' + entries[i][2] hint.appendChild(kbd(entries[i][0])) if (entries[i][1]) hint.appendChild(document.createTextNode(entries[i][1])) keyHintsContainer.appendChild(hint) } } function renderProvenance() { if (!provenanceContainer) return provenanceContainer.textContent = '' if (!provenance) return // 출처는 종류만 보여준다 — 근거 개수는 설정의 제안 기록에서 본다. var sourceLabel = provenance.mode === 'local-memory' ? i18nStrings.suggestionSourceMemory : i18nStrings.suggestionSourceModel provenanceContainer.textContent = sourceLabel || '' } function onItemClick(event) { var target = event.currentTarget var index = Number(target.getAttribute('data-index')) if (!window.popupAPI) return window.popupAPI.send('suggestionPopup:accept', index) } function applyPayload(payload) { if (!payload) return if (payload._i18n) i18nStrings = payload._i18n if (payload._keyHints) keyHints = payload._keyHints candidates = (payload.candidates || []).map(function (candidate) { return candidate && candidate.text ? candidate.text : String(candidate) }) activeIndex = payload.activeIndex || 0 if (payload.targetTotal !== undefined) targetTotal = payload.targetTotal || 0 if (payload.generating !== undefined) generating = payload.generating === true if (payload.warmingUp !== undefined) warmingUp = payload.warmingUp === true if (payload.partialText !== undefined) partialText = payload.partialText || null if (payload.provenance !== undefined) provenance = payload.provenance || null } function startTick() { if (tickTimer) return tickTimer = setInterval(function () { if (!generating && !warmingUp) { clearInterval(tickTimer) tickTimer = null generatingSince = 0 return } render() }, 500) } function handleShow(payload) { applyPayload(payload) if (payload && payload.generating && candidates.length === 0) generatingSince = Date.now() startTick() if (panel) panel.classList.add('visible') render() } function handleUpdate(payload) { var wasBusy = generating && candidates.length === 0 applyPayload(payload) var isBusy = generating && candidates.length === 0 if (isBusy && !wasBusy) generatingSince = Date.now() startTick() render() } function handleHide() { if (panel) panel.classList.remove('visible') if (tickTimer) { clearInterval(tickTimer) tickTimer = null } generatingSince = 0 generating = false warmingUp = false partialText = null provenance = null candidates = [] activeIndex = 0 targetTotal = 0 keyHints = null candidatesContainer.textContent = '' if (footer) footer.hidden = true if (provenanceContainer) provenanceContainer.textContent = '' if (progressContainer) progressContainer.textContent = '' if (keyHintsContainer) keyHintsContainer.textContent = '' } if (closeButton) { // 마우스로도 닫을 수 있어야 한다 (키보드만 있으면 불편하다는 피드백). closeButton.addEventListener('click', function () { handleHide() if (window.popupAPI) window.popupAPI.send('suggestionPopup:dismiss') }) } if (window.popupAPI) { window.popupAPI.on('suggestionPopup:show', handleShow) window.popupAPI.on('suggestionPopup:update', handleUpdate) window.popupAPI.on('suggestionPopup:hide', handleHide) } })()