fix(suggestion): paste after the shortcut keys are released and keep typing detection stable

Ctrl+Alt+Enter pasted while Ctrl+Alt were still down, so the target app got
Ctrl+Alt+V; accepting now closes the panel and waits for the modifiers to be
released. Candidates are accepted on pointer press because the list is
redrawn as new candidates stream in, which swallowed clicks.

The typing gate identified the focused field by its bounds, so chat boxes
that grow while typing looked like a new field on every keystroke and were
reported as "not typing". Fields are now keyed by window, control type and
name, and a mouse click re-baselines the text instead. The decision log
includes both gate values.

The live-caption model selector moves to the caption section of the General
tab, next to the other caption settings.
This commit is contained in:
Yun Chan 2026-09-24 22:01:10 +09:00
parent 4b0f685941
commit da0da98285
8 changed files with 144 additions and 55 deletions

View file

@ -41,6 +41,7 @@ import { buildSuggestionPrompt } from './llm-prompts'
import { getInputTelemetryService, type TypingContext } from './InputTelemetryService'
import { getPersonalGraphService } from './PersonalGraphService'
import { getTextInsertService } from './TextInsertService'
import { waitForModifiersReleased } from './modifier-state'
const logger = getLogger('SuggestionService')
@ -1074,6 +1075,8 @@ class SuggestionService extends EventEmitter {
context.isPassword,
context.isComposing,
context.anchor ? 'A' : '-',
context.editedSinceFocus,
context.typedRecently,
getLocalLLMService().isAvailable(),
this.isEnabled()
].join('|')
@ -1082,7 +1085,8 @@ class SuggestionService extends EventEmitter {
this._lastDecisionSignature = signature
logger.info(
`제안 판단 입력: avail=${context.available} edit=${context.isEditable} pw=${context.isPassword} ` +
`comp=${context.isComposing} prefixLen=${context.prefix.length} idle=${context.idleMs}ms ` +
`comp=${context.isComposing} edited=${context.editedSinceFocus} typed=${context.typedRecently} ` +
`prefixLen=${context.prefix.length} idle=${context.idleMs}ms ` +
`app=${context.appName ?? '-'} enabled=${this.isEnabled()} ` +
`model=${getLocalLLMService().isAvailable()} anchor=${context.anchor ? 'yes' : 'no'}`
)
@ -1244,11 +1248,24 @@ class SuggestionService extends EventEmitter {
}
const candidate = this._candidates[this._activeIndex]
if (!candidate) return { ok: false, reason: 'already-visible' }
const text = candidate.text
const appName = this._appName
const windowTitle = this._windowTitle
// 먼저 창을 닫고 생성을 멈춘다 — 수락은 즉시 반응해야 한다.
this.dismiss('accepted')
this.emit('state-changed', this.getState())
// 단축키로 수락했다면 사용자가 아직 Ctrl+Alt 를 쥐고 있다. 그대로 Ctrl+V 를 보내면
// 대상 앱에 Ctrl+Alt+V 가 들어가 붙여넣기가 되지 않는다(실측) — 뗄 때까지 기다린다.
if (!(await waitForModifiersReleased())) {
logger.warn('수정자 키가 떼어지지 않은 채 제안을 삽입한다 (1.5초 초과)')
}
const method = configGet('insertMethod')
try {
const result = await getTextInsertService().insertText(
candidate.text,
text,
method === 'keyboard' ? 'keyboard' : 'clipboard'
)
if (!result.success) {
@ -1260,16 +1277,9 @@ class SuggestionService extends EventEmitter {
return { ok: false, reason: 'generation-failed' }
}
this._markAccepted(candidate.text)
this._markAccepted(text)
// 수락한 문장은 사용자 문체의 확실한 표본이다 (학습 동의 시에만 저장됨).
getInputTelemetryService().recordExternalText(candidate.text, {
appName: this._appName,
windowTitle: this._windowTitle,
source: 'suggestion'
})
this.dismiss('accepted')
this.emit('state-changed', this.getState())
getInputTelemetryService().recordExternalText(text, { appName, windowTitle, source: 'suggestion' })
return { ok: true }
}