fix(suggestion): keep a shown suggestion while the user picks it, and suggest in terminals

A candidate list was cleared about two seconds after it appeared: once typing
paused, the not-typing rule dismissed the visible overlay, so a click or
Ctrl+Alt+Enter found nothing to insert. The rule now only stops new
requests; a visible overlay stays until it is accepted, dismissed or goes
stale.

Terminals were excluded from suggestions because the whole screen buffer
reads as the input and the last line is usually a status bar. The input line
is now extracted (Claude Code/Codex >, starship, PowerShell, cmd and POSIX
prompts, wrapped continuation lines) and used as the prefix; no prompt means
no suggestion. Terminals stay out of phrase learning. Accepting with nothing
shown and successful inserts are now logged.
This commit is contained in:
Yun Chan 2026-09-27 22:43:22 +09:00
parent 0273c6abaa
commit 7e0c20b510
3 changed files with 143 additions and 5 deletions

View file

@ -24,7 +24,8 @@ import {
parseSuggestionCandidates,
sanitizeSuggestionLine,
selectPhraseHints,
TERMINAL_APPS,
isTerminalApp,
extractTerminalPromptPrefix,
type AnchorKind,
type SuggestionCandidate,
type SuggestionProvenance,
@ -335,7 +336,11 @@ class SuggestionService extends EventEmitter {
*
* 정책 판단 → (stale 정리) → 필요하면 생성.
*/
handleTypingContext(context: TypingContext): void {
handleTypingContext(input: TypingContext): void {
// 터미널은 화면 전체가 읽힌다 — 입력 줄만 접두로 쓴다. 입력 줄이 없으면 빈 접두(제안 안 함).
const context: TypingContext = isTerminalApp(input.appName)
? { ...input, prefix: extractTerminalPromptPrefix(input.fullText ?? input.prefix) ?? '' }
: input
this._lastContext = context
const now = Date.now()
this._lastContextAt = now
@ -1247,7 +1252,11 @@ class SuggestionService extends EventEmitter {
this._activeIndex = index
}
const candidate = this._candidates[this._activeIndex]
if (!candidate) return { ok: false, reason: 'already-visible' }
if (!candidate) {
// 창이 이미 닫힌 뒤의 클릭·단축키 — 조용히 끝나면 "골라도 안 들어간다" 의 원인이 안 보인다.
logger.info(`제안 수락 무시: 표시 중인 후보 없음 (index=${index ?? 'active'})`)
return { ok: false, reason: 'already-visible' }
}
const text = candidate.text
const appName = this._appName
const windowTitle = this._windowTitle
@ -1277,6 +1286,7 @@ class SuggestionService extends EventEmitter {
return { ok: false, reason: 'generation-failed' }
}
logger.info(`제안 수락: ${text.length}자 삽입 (method=${method === 'keyboard' ? 'keyboard' : 'clipboard'}, app=${appName ?? '-'})`)
this._markAccepted(text)
// 수락한 문장은 사용자 문체의 확실한 표본이다 (학습 동의 시에만 저장됨).
getInputTelemetryService().recordExternalText(text, { appName, windowTitle, source: 'suggestion' })
@ -1331,7 +1341,8 @@ class SuggestionService extends EventEmitter {
maxCandidates: SUGGESTION_DEFAULTS.maxCandidates,
maxChars: SUGGESTION_MAX_OUTPUT_CHARS,
// 터미널은 셸 프롬프트라 문장 제안이 의미 없다 — 사용자 목록과 무관하게 뺀다.
excludedApps: [...configGet('inputExcludedApps'), ...TERMINAL_APPS]
// 터미널도 제안한다(입력 줄만 추출). 학습 제외는 InputTelemetryService 가 따로 한다.
excludedApps: [...configGet('inputExcludedApps')]
}
}