release: ship v1.6.0 with paged suggestions and a cleaner phrase memory
Some checks failed
deploy-site / deploy (push) Failing after 39s
release / release-windows (push) Failing after 3m41s
portable-unsigned / portable-windows (push) Failing after 12m23s

Next-sentence suggestions now arrive one at a time up to twelve, shown three
per page with Ctrl+Alt+Up/Down to move, Left/Right to page, Enter to accept
and Esc to close; old default bindings migrate and the panel guide follows the
live bindings. The overlay is redesigned, stays put while candidates stream
and sits outside the input box when no caret is reported.

The personal phrase memory stops learning from terminals, code editors and
the coding-agent hub, ignores symbol-heavy lines and empty-field placeholders,
and prunes existing entries that break those rules.

Fixes suggestion keys starting dictation, installs stuck on a pre-1.5.0
speech engine without the focus endpoint, Ollama runner windows flashing
while typing, the speech engine starting twice, and cold-model timeouts.
Live captions can be dragged to a remembered position and show a waiting
notice until the first line arrives.

Bumps the product version to 1.6.0 (Android/iOS build 1060000).
This commit is contained in:
Yun Chan 2026-09-24 19:56:28 +09:00
parent 856e375f3e
commit 2fe20fa7b5
71 changed files with 2469 additions and 366 deletions

View file

@ -20,9 +20,12 @@ import {
decideSuggestion,
decideSuggestionRefresh,
isAppExcluded,
matchesSessionPrefix,
parseSuggestionCandidates,
sanitizeSuggestionLine,
selectPhraseHints,
TERMINAL_APPS,
type AnchorKind,
type SuggestionCandidate,
type SuggestionProvenance,
type SuggestionSkipReason,
@ -76,18 +79,24 @@ const MAX_RAW_OUTPUT_CHARS = 1200
/**
* 모델을 메모리에 유지하는 시간.
*
* 자동 입력 제안은 반복 호출 경로이므로, 짧은 유휴 구간만 모델을 유지한다.
* 콜드 리로드 실측(11.4s)이 요청 타임아웃(8s)보다 길어, 짧은 유휴 유지 시간은
* 유휴 후 첫 요청을 항상 타임아웃시켰다 — 충분히 길게 유지한다.
*/
const SUGGESTION_KEEP_ALIVE = '2m'
const SUGGESTION_KEEP_ALIVE = '10m'
const SUGGESTION_KEEP_ALIVE_MS = 10 * 60_000
class SuggestionService extends EventEmitter {
private _candidates: SuggestionCandidate[] = []
private _activeIndex = 0
private _anchor: UiRect | null = null
/** anchor 가 케어렛인지 요소 전체인지 — 오버레이가 배치 전략을 고르는 근거 */
private _anchorKind: AnchorKind = null
private _appName: string | null = null
private _windowTitle: string | null = null
/** 후보를 만든 시점의 접두 — 접두가 달라지면 stale */
private _generatedForPrefix = ''
/** 이 세션이 채우려는 후보 총량 (모델 세션 12 / 로컬 기억 세션은 현재 개수) */
private _targetTotal = 0
private _lastSkipReason: SuggestionSkipReason | null = null
private _lastDecisionSignature = ''
/** 생성 중 — 후보 도착 전에도 오버레이를 띄운다 */
@ -122,6 +131,10 @@ class SuggestionService extends EventEmitter {
private _provenance: SuggestionProvenance | null = null
private _lastRequestAt = 0
/** 취소된 요청의 부담을 되돌리기 위해 직전 값을 기억한다. */
private _prevRequestAt = 0
/** 모델이 메모리에 남아 있다고 볼 수 있는 시각 (ms). 이 시각 이후에는 콜드 리로드로 본다. */
private _modelWarmUntil = 0
private _minuteWindowStart = 0
private _minuteCount = 0
private _dayKey = ''
@ -129,6 +142,9 @@ class SuggestionService extends EventEmitter {
private _inFlight = false
private _abort: AbortController | null = null
/** 채우기 루프(2번째 이후 후보) 진행 중 — _inFlight 와 별개다 (예산/타임아웃 카운트 제외). */
private _filling = false
private _fillAbort: AbortController | null = null
private _timeoutTimer: NodeJS.Timeout | null = null
private _lastLatencyMs: number | null = null
private _warmUpPromise: Promise<void> | null = null
@ -182,7 +198,9 @@ class SuggestionService extends EventEmitter {
partialText: this._partialText || null,
candidates: [...this._candidates],
activeIndex: this._activeIndex,
targetTotal: this._targetTotal,
anchor: this._anchor,
anchorKind: this._anchorKind,
appName: this._appName,
updatedAt: now,
lastSkipReason: this._lastSkipReason,
@ -263,6 +281,7 @@ class SuggestionService extends EventEmitter {
})
for await (const chunk of stream) void chunk
if (abort.signal.aborted) return
this._modelWarmUntil = Date.now() + SUGGESTION_KEEP_ALIVE_MS - 30_000
logger.info(`제안 모델 워밍업 완료 (model=${model})`)
} catch (error) {
if (!abort.signal.aborted) {
@ -275,6 +294,10 @@ class SuggestionService extends EventEmitter {
}
} finally {
this._warmingUp = false
// 'state-changed' 뿐 아니라 'updated' 도 보내야 한다 — 오버레이는 'updated' 를
// 듣고 표시 여부를 판단하는데, 이게 빠지면 타이핑이 멈춘 사이 워밍업이 끝나도
// "준비 중" 오버레이가 20초 TTL 까지 그대로 남는다.
this.emit('updated', this.getState())
this.emit('state-changed', this.getState())
}
}
@ -322,6 +345,16 @@ class SuggestionService extends EventEmitter {
this._logDecisionInputs(context)
this._abortStaleGeneration(context.prefix)
// 세션(페이지 넘기며 보는 고정 목록)이 후보를 보여주는 중이면, 다음 문장이
// 시작된 순간 — 이어 치기로 자란 것도 포함, IME 마지막 글자 조합만 예외 —
// 즉시 세션을 끝낸다(사용자 요청 사양: 계속 자라는 접두를 따라가지 않는다).
// 다음 멈춤에서 정책 게이트를 다시 거쳐 새 세션이 시작된다.
if (this.isVisible && !matchesSessionPrefix(this._generatedForPrefix, context.prefix)) {
this.dismiss('stale')
this.emit('state-changed', this.getState())
return
}
// 연속 실패 쿨다운. 모델이 다른 작업으로 바쁘면(로컬 에이전트 동시 사용 등)
// 요청이 계속 타임아웃되어 스피너만 깜빡인다 — 잠시 요청을 멈춘다.
if (Date.now() < this._cooldownUntil) {
@ -352,17 +385,19 @@ class SuggestionService extends EventEmitter {
}
const decision = decideSuggestion({
enabled: this.isEnabled(),
// 접두가 충분히 자랐으면 "이미 떠 있음" 으로 막지 않는다 (지속 갱신).
// (decideSuggestion 의 overlayVisible 인자는 아래에서 계산한다)
modelAvailable: getLocalLLMService().isAvailable(),
// 생성 중에도 "이미 진행 중" 으로 취급해 중복 요청과 깜빡임을 막는다.
overlayVisible: this.isPresentationActive && !this._shouldRegenerate(context.prefix),
// 생성 중이거나 세션이 떠 있으면 "이미 진행 중" 으로 취급해 중복 요청과
// 깜빡임을 막는다. 접두가 어긋난 세션은 위에서 이미 dismiss 하고 return 했으므로
// 여기 도달했다는 것 자체가 "그대로 유효한 세션" 이라는 뜻이다.
overlayVisible: this.isPresentationActive,
composing: context.isComposing,
hasSelection: context.hasSelection,
isPassword: context.isPassword,
isEditable: context.isEditable,
appName: context.appName,
excludedApps: config.excludedApps,
editedSinceFocus: context.editedSinceFocus,
typedRecently: context.typedRecently,
prefix: context.prefix,
idleMs: context.idleMs,
triggerDelayMs: config.triggerDelayMs,
@ -383,13 +418,6 @@ class SuggestionService extends EventEmitter {
return
}
// 이미 보여준 제안이 현재 접두와 어긋나면 즉시 지운다 (tab-completion 표준 동작).
if (this.isVisible && !this._matchesGeneratedPrefix(context.prefix)) {
this.dismiss('stale')
this.emit('state-changed', this.getState())
return
}
if (decision.action === 'skip') {
logger.debug(`제안 건너: ${decision.reason}`)
this._lastSkipReason = decision.reason
@ -420,6 +448,7 @@ class SuggestionService extends EventEmitter {
}
this._warmingUp = true
this._anchor = context.anchor
this._anchorKind = context.anchorKind
this._appName = context.appName
// 워밍업 안내도 수명이 있다 — 모델이 뜬 뒤 갱신이 없으면 스스로 사라진다.
this._armVisibleTtl()
@ -430,6 +459,32 @@ class SuggestionService extends EventEmitter {
return
}
// 모델이 유휴로 내려갔을 것으로 보이면 생성 대신 워밍업부터 한다.
// 콜드 리로드(실측 11.4s)는 요청 타임아웃(8s)보다 길어 그대로 요청하면 항상 시간 초과한다.
if (now >= this._modelWarmUntil) {
if (this._canUseLocalMemory(context, config, now)) {
const shown = this._publishLocalMemory(
this._currentPrefix(context),
context,
config.maxCandidates,
config.maxChars,
0,
null
)
if (shown) return
}
void this.warmUp()
this._warmingUp = true
this._anchor = context.anchor
this._anchorKind = context.anchorKind
this._appName = context.appName
this._armVisibleTtl()
this.emit('updated', this.getState())
this._lastSkipReason = 'model-unavailable'
this.emit('state-changed', this.getState())
return
}
// 비동기 실패가 조용히 사라지면 기능이 죽은 이유를 알 수 없다.
void this._generate(decision.prefix, context, config.maxCandidates, config.maxChars).catch(
(error: unknown) => {
@ -457,8 +512,12 @@ class SuggestionService extends EventEmitter {
const prefix = context.prefix.replace(/\s+$/u, '')
if (prefix.length < 1) return { ok: false, reason: 'empty-prefix' }
// 명시적 사용자 액션(단축키/설정)이다 — 텔레메트리가 판단한 "최근에 타이핑했는가" 에
// 좌우되지 않아야 한다. 그대로 넘기면 _generate 실패 경로의 _canUseLocalMemory 가
// (클릭만 한 뒤 수동 요청 같은 경우) not-typing 으로 막아 버린다.
const explicitContext: TypingContext = { ...context, editedSinceFocus: true, typedRecently: true }
const config = this.readPolicyConfig()
await this._generate(prefix, context, config.maxCandidates, config.maxChars).catch((error: unknown) => {
await this._generate(prefix, explicitContext, config.maxCandidates, config.maxChars).catch((error: unknown) => {
logger.warn(`수동 제안 생성 예외: ${error instanceof Error ? error.message : String(error)}`)
this._releaseGeneration()
})
@ -480,6 +539,8 @@ class SuggestionService extends EventEmitter {
isEditable: context.isEditable,
appName: context.appName,
excludedApps: config.excludedApps,
editedSinceFocus: context.editedSinceFocus,
typedRecently: context.typedRecently,
prefix: context.prefix,
idleMs: context.idleMs,
triggerDelayMs: config.triggerDelayMs,
@ -560,9 +621,11 @@ class SuggestionService extends EventEmitter {
this._candidates = candidates.map((text, index) => ({ text, rank: index }))
this._activeIndex = 0
this._anchor = context.anchor
this._anchorKind = context.anchorKind
this._appName = context.appName
this._windowTitle = context.windowTitle
this._generatedForPrefix = trimmedPrefix
this._targetTotal = candidates.length
this._lastSkipReason = null
this._lastLatencyMs = latencyMs
this._provenance = {
@ -638,6 +701,7 @@ class SuggestionService extends EventEmitter {
this._lastRequestedPrefix = prefix.replace(/\s+$/u, '')
this._warmingUp = false
this._provenance = null
this._prevRequestAt = this._lastRequestAt
this._lastRequestAt = Date.now()
this._minuteCount += 1
this._dayCount += 1
@ -647,6 +711,7 @@ class SuggestionService extends EventEmitter {
this._candidates = []
this._activeIndex = 0
this._anchor = context.anchor
this._anchorKind = context.anchorKind
this._appName = context.appName
this._windowTitle = context.windowTitle
this._armVisibleTtl()
@ -671,13 +736,15 @@ class SuggestionService extends EventEmitter {
const hints = [...memory.relatedHints, ...memory.phraseHints]
.filter((value, index, list) => list.indexOf(value) === index)
.slice(0, 5)
// 세션의 첫 요청은 딱 1개만 청한다 — 한꺼번에 여러 개를 요청하면 느리다(사용자
// 요청). 첫 후보를 보여준 뒤 나머지는 채우기 루프가 하나씩 순차로 더 만든다.
const { systemPrompt, text } = buildSuggestionPrompt({
prefix: trimmedPrefix,
appName: context.appName,
windowTitle: context.windowTitle,
phraseHints: hints,
continuationHints,
candidates: maxCandidates,
candidates: 1,
maxChars
})
@ -697,6 +764,8 @@ class SuggestionService extends EventEmitter {
for await (const chunk of stream) {
if (abort.signal.aborted) break
raw += chunk
// 최소 한 청크라도 도착했으면 모델이 메모리에 올라온 것 — 유지 시각을 갱신한다.
this._modelWarmUntil = Date.now() + SUGGESTION_KEEP_ALIVE_MS - 30_000
// 도착하는 대로 오버레이에 흘려보낸다 — 사용자는 "계속 생성되는" 것을 본다.
// IPC 과다 방출을 막기 위해 120ms 간격으로만 보낸다.
@ -783,7 +852,7 @@ class SuggestionService extends EventEmitter {
return
}
const candidates = parseSuggestionCandidates(raw, trimmedPrefix, maxCandidates, maxChars)
const candidates = parseSuggestionCandidates(raw, trimmedPrefix, 1, maxChars)
if (candidates.length === 0) {
this._lastSkipReason = 'generation-failed'
logger.info('제안 후보가 비어 있음 (모델 출력 정제 후)')
@ -809,14 +878,15 @@ class SuggestionService extends EventEmitter {
this._lastLatencyMs = latencyMs
this._consecutiveFailures = 0
this._cooldownUntil = 0
this._generating = false
this._partialText = ''
this._candidates = candidates.map((candidate, index) => ({ text: candidate, rank: index }))
this._activeIndex = 0
this._anchor = context.anchor
this._anchorKind = context.anchorKind
this._appName = context.appName
this._windowTitle = context.windowTitle
this._generatedForPrefix = trimmedPrefix
this._targetTotal = SUGGESTION_DEFAULTS.maxCandidatesTotal
this._lastSkipReason = null
this._provenance = {
mode: 'local-model',
@ -827,9 +897,18 @@ class SuggestionService extends EventEmitter {
}
this._record({ prefix: trimmedPrefix, text: candidates[0], model, latencyMs, candidateCount: candidates.length })
logger.info(`제안 ${candidates.length}개 생성 (${latencyMs}ms, model=${model})`)
logger.info(`제안 첫 후보 생성 (${latencyMs}ms, model=${model}) — 최대 ${SUGGESTION_DEFAULTS.maxCandidatesTotal}개까지 순차로 채운다`)
this._armVisibleTtl()
this.emit('updated', this.getState())
// generating 은 계속 true 로 남는다 — 채우기 루프가 백그라운드에서 나머지를
// 하나씩 청한다("더 온다" 를 UI 에 알린다). _releaseGeneration() 이 이를
// 지우지 않도록 _filling 을 먼저 켠다.
this._filling = true
void this._runFillLoop(token, context, maxChars).catch((error: unknown) => {
logger.warn(`제안 채우기 루프 예외: ${error instanceof Error ? error.message : String(error)}`)
this._filling = false
})
} finally {
// 어떤 경로로 끝나든 진행 플래그를 해제한다 —
// 예외가 어디로 새든 다음 제안이 조용히 막히지 않는다.
@ -837,6 +916,126 @@ class SuggestionService extends EventEmitter {
}
}
// ── 채우기 루프 (2번째 이후 후보, 최대 12개) ────────────
/**
* 첫 후보 공개 뒤 나머지를 하나씩 순차로 채운다.
*
* 종료 조건: 12개 도달, 연속 2번 새 후보 없음, 세대 토큰이 바뀜(세션 종료).
* `_generate` 의 예산/타임아웃/실패 카운트와 무관하다 — 채우기는 그 어느 것도
* 소비하지 않는다(설계). 시간 초과된 채우기 요청은 조용히 다음으로 넘어간다.
*/
private async _runFillLoop(token: number, context: TypingContext, maxChars: number): Promise<void> {
let consecutiveEmpty = 0
while (
token === this._generationToken &&
this._candidates.length < SUGGESTION_DEFAULTS.maxCandidatesTotal &&
consecutiveEmpty < 2
) {
const appended = await this._fillOne(token, context, maxChars)
if (token !== this._generationToken) break
consecutiveEmpty = appended ? 0 : consecutiveEmpty + 1
}
if (token === this._generationToken) {
this._filling = false
this._generating = false
this.emit('updated', this.getState())
}
}
/** 채우기 루프 한 스텝 — 후보 1개를 요청해 고유하면 덧붙인다. */
private async _fillOne(token: number, context: TypingContext, maxChars: number): Promise<boolean> {
const model = this.resolveModel()
if (!model) return false
const abort = new AbortController()
this._fillAbort = abort
const timeoutMs = this.readTimeoutMs()
const timer = setTimeout(() => {
if (!abort.signal.aborted) abort.abort()
}, timeoutMs)
timer.unref?.()
try {
// 세션이 고정한 접두를 쓴다 — context.prefix 는 그사이 바뀌었을 수 있지만,
// 바뀌었다면 이미 위(handleTypingContext)에서 세션이 dismiss 되어 토큰이
// 달라져 있으므로 이 루프는 다음 체크에서 멈춘다.
const prefix = this._generatedForPrefix
const memory = this._collectMemoryHints(prefix, context.appName)
const hints = [...memory.relatedHints, ...memory.phraseHints]
.filter((value, index, list) => list.indexOf(value) === index)
.slice(0, 5)
const avoidCandidates = this._candidates.map((candidate) => candidate.text)
const { systemPrompt, text } = buildSuggestionPrompt({
prefix,
appName: context.appName,
windowTitle: context.windowTitle,
phraseHints: hints,
continuationHints: memory.continuationHints,
avoidCandidates,
candidates: 1,
maxChars
})
let raw = ''
const stream = getLocalLLMService().streamGenerate(text, {
model,
systemPrompt,
temperature: SUGGESTION_DEFAULTS.temperature,
maxTokens: SUGGESTION_DEFAULTS.maxOutputTokens,
signal: abort.signal,
keepAlive: SUGGESTION_KEEP_ALIVE
})
for await (const chunk of stream) {
if (abort.signal.aborted) break
raw += chunk
this._modelWarmUntil = Date.now() + SUGGESTION_KEEP_ALIVE_MS - 30_000
if (raw.length >= MAX_RAW_OUTPUT_CHARS) break
}
if (abort.signal.aborted || token !== this._generationToken) return false
const parsed = parseSuggestionCandidates(raw, prefix, 1, maxChars)
if (parsed.length === 0) return false
const candidateText = parsed[0]
if (token !== this._generationToken || this._isDuplicateCandidate(candidateText)) return false
this._candidates.push({ text: candidateText, rank: this._candidates.length })
this._armVisibleTtl()
this.emit('updated', this.getState())
this._record({
prefix,
text: candidateText,
model,
latencyMs: 0,
candidateCount: this._candidates.length
})
return true
} catch (error) {
if (!abort.signal.aborted) {
logger.debug(
`제안 채우기 요청 실패 — 조용히 다음으로 넘어간다: ${error instanceof Error ? error.message : String(error)}`
)
}
return false
} finally {
clearTimeout(timer)
if (this._fillAbort === abort) this._fillAbort = null
}
}
/** 정규화 후 완전 중복이거나 기존 후보의 접두/확장이면 중복으로 본다. */
private _isDuplicateCandidate(candidate: string): boolean {
const normalized = candidate.replace(/\s+/gu, ' ').trim().toLowerCase()
return this._candidates.some((existing) => {
const existingNormalized = existing.text.replace(/\s+/gu, ' ').trim().toLowerCase()
return (
existingNormalized === normalized ||
existingNormalized.startsWith(normalized) ||
normalized.startsWith(existingNormalized)
)
})
}
/**
* 생성 플래그를 무조건 해제한다 (누수 감시 포함).
*
@ -844,7 +1043,9 @@ class SuggestionService extends EventEmitter {
*/
private _releaseGeneration(): void {
this._inFlight = false
this._generating = false
// 채우기 루프가 막 시작됐으면 generating 을 그대로 둔다 — 아직 더 올 게 있다.
// (루프 자신이 끝날 때 스스로 false 로 내린다)
if (!this._filling) this._generating = false
this._partialText = ''
}
@ -923,10 +1124,6 @@ class SuggestionService extends EventEmitter {
}
}
private _shouldRegenerate(currentPrefix: string): boolean {
return decideSuggestionRefresh(this._generatedForPrefix, currentPrefix) === 'regenerate'
}
private _abortStaleGeneration(currentPrefix: string): void {
if (!this._inFlight || !this._abort || this._abort.signal.aborted) return
const refresh = decideSuggestionRefresh(this._lastRequestedPrefix, currentPrefix)
@ -934,6 +1131,15 @@ class SuggestionService extends EventEmitter {
this._generationToken += 1
this._abort.abort()
// 취소된 요청은 속도 제한 예산을 쓰지 않았던 것으로 되돌린다.
// (IME 조합 중 마지막 글자가 바뀌어 stale 로 잡히면, 취소가 예산을 갉아먹어
// 정작 사용자가 멈췄을 때 rate-limited 로 막히던 문제)
this._lastRequestAt = this._prevRequestAt
this._minuteCount = Math.max(0, this._minuteCount - 1)
this._dayCount = Math.max(0, this._dayCount - 1)
// 보여줄 후보가 없으면 스피너만 남는다 — 오버레이를 지운다.
if (this._candidates.length === 0) this.dismiss('stale')
logger.debug(`제안 생성 취소 (${refresh}) — 다음 정상 입력 문맥에서만 재평가`)
}
@ -960,32 +1166,46 @@ class SuggestionService extends EventEmitter {
this.emit('updated', this.getState())
}
private _matchesGeneratedPrefix(currentPrefix: string): boolean {
const current = currentPrefix.replace(/\s+$/u, '')
if (!this._generatedForPrefix) return false
return current === this._generatedForPrefix || current.startsWith(this._generatedForPrefix)
}
// ── 사용자 동작 ───────────────────────────────────────
/** 이전 후보로 순환. */
/** 이전 후보로 순환 (전체 후보를 가로질러, 페이지 무관). */
previous(): SuggestionState {
if (this._candidates.length > 1) {
this._activeIndex =
(this._activeIndex - 1 + this._candidates.length) % this._candidates.length
this.emit('updated', this.getState())
this._moveActive((this._activeIndex - 1 + this._candidates.length) % this._candidates.length)
}
return this.getState()
}
next(): SuggestionState {
if (this._candidates.length > 1) {
this._activeIndex = (this._activeIndex + 1) % this._candidates.length
this.emit('updated', this.getState())
this._moveActive((this._activeIndex + 1) % this._candidates.length)
}
return this.getState()
}
/** 다음 페이지의 첫 항목으로 — 그 페이지에 후보가 없으면 그대로 둔다. */
pageNext(): SuggestionState {
const pageSize = SUGGESTION_DEFAULTS.pageSize
const totalPages = Math.ceil(this._candidates.length / pageSize)
const currentPage = Math.floor(this._activeIndex / pageSize)
if (currentPage < totalPages - 1) this._moveActive((currentPage + 1) * pageSize)
return this.getState()
}
/** 이전 페이지의 첫 항목으로 — 이미 첫 페이지면 그대로 둔다. */
pagePrev(): SuggestionState {
const currentPage = Math.floor(this._activeIndex / SUGGESTION_DEFAULTS.pageSize)
if (currentPage > 0) this._moveActive((currentPage - 1) * SUGGESTION_DEFAULTS.pageSize)
return this.getState()
}
/** 사용자가 후보를 훑는 중에는 표시 수명이 끝나 창이 닫히면 안 된다. */
private _moveActive(index: number): void {
this._activeIndex = index
this._armVisibleTtl()
this.emit('updated', this.getState())
}
dismiss(reason: SuggestionSkipReason = 'dismissed'): void {
if (reason === 'dismissed') {
// 명시적 닫기: 잠깐 조용히 있고, 진행 중 생성은 무효화한다.
@ -1002,16 +1222,24 @@ class SuggestionService extends EventEmitter {
this._partialText = ''
this._activeIndex = 0
this._anchor = null
this._anchorKind = null
this._generatedForPrefix = ''
this._targetTotal = 0
this._provenance = null
this._lastSkipReason = reason
this._abort?.abort()
this._abort = null
// 채우기 루프도 함께 끝낸다 — accept/dismiss/stale 은 전부 세션 종료다(설계).
this._filling = false
this._fillAbort?.abort()
this._fillAbort = null
if (this._timeoutTimer) {
clearTimeout(this._timeoutTimer)
this._timeoutTimer = null
}
this._cancelWarmUp()
// 워밍업은 오버레이 표시와 독립된 요청이다 — 포커스 전환/stale 등으로 오버레이를
// 지울 때마다 취소하면 모델이 영영 안 뜬다. 설정에서 기능을 끌 때만 취소한다.
if (reason === 'disabled') this._cancelWarmUp()
if (wasVisible || reason === 'dismissed') {
this.emit('cleared', { reason })
}
@ -1100,7 +1328,8 @@ class SuggestionService extends EventEmitter {
dailyBudget: configGet('suggestionDailyBudget') || SUGGESTION_DEFAULTS.dailyBudget,
maxCandidates: SUGGESTION_DEFAULTS.maxCandidates,
maxChars: SUGGESTION_MAX_OUTPUT_CHARS,
excludedApps: [...configGet('inputExcludedApps')]
// 터미널은 셸 프롬프트라 문장 제안이 의미 없다 — 사용자 목록과 무관하게 뺀다.
excludedApps: [...configGet('inputExcludedApps'), ...TERMINAL_APPS]
}
}