fix(suggestion): place overlay in DIPs, size it to content, and harden suggestion sessions

This commit is contained in:
Yun Chan 2026-09-28 02:16:15 +09:00
parent 75e053d72f
commit 9e5b94ced2
18 changed files with 2178 additions and 558 deletions

View file

@ -22,15 +22,20 @@ import {
SENTENCE_TERMINATORS,
SUGGESTION_MAX_OUTPUT_CHARS,
normalizeRequestPrefix,
normalizeSessionPrefix
normalizeSessionPrefix,
type SuggestionJoint
} from './suggestion-text'
import { NON_PROSE_GLYPH_PATTERN } from './terminal-prompt'
import type { AnchorKind, PhysicalRect } from './popup-placement'
import { countLexicalWords } from './text-segmentation'
export {
SUGGESTION_CONTEXT_MAX_CHARS,
SUGGESTION_MAX_OUTPUT_CHARS,
buildLocalSuggestionCandidateEntries,
buildLocalSuggestionCandidates,
endsSentence,
joinSuggestion,
normalizeRequestPrefix,
normalizeSessionPrefix,
normalizeSuggestionText,
@ -38,29 +43,27 @@ export {
sanitizeSuggestionLine,
stripModelArtifacts
} from './suggestion-text'
export type { LocalSuggestionHints } from './suggestion-text'
export type { LocalSuggestionCandidate, LocalSuggestionHints, SuggestionJoint } from './suggestion-text'
export { extractTerminalPromptPrefix } from './terminal-prompt'
// ============================================================
// 기하
// 기하 — 정본은 popup-placement.ts (좌표 공간 · 배치 정책). 기존 import 경로를 유지한다.
// ============================================================
export interface UiRect {
x: number
y: number
width: number
height: number
}
/**
* 앵커 rect 가 케어렛(한 줄 위치)인지 포커스 요소 전체인지.
*
* 케어렛을 못 주는 제공자가 많아(Chrome, Windows Terminal 등 caret=-1) 그때는
* elementRect 로 폴백하는데, 두 경우는 배치 전략이 달라야 한다 — 케어렛은 "그
* 줄 아래" 에 붙이면 되지만, elementRect(멀티라인/큰 입력창 전체)는 그 규칙을
* 그대로 쓰면 텍스트와 멀리 떨어지거나 텍스트 위에 겹친다.
*/
export type AnchorKind = 'caret' | 'element' | null
export {
anchorFloatingPanel,
anchorFloatingPanelInDips,
asDipPoint,
asDipRect
} from './popup-placement'
export type {
AnchorKind,
DipPoint,
DipRect,
PhysicalRect,
ScreenGeometryPort,
UiRect
} from './popup-placement'
/** 마우스 이동 누적 전에 무시할 최소 이동량(px). 미세抖动 노이즈 제거. */
export const MOUSE_MOVE_NOISE_FLOOR_PX = 2
@ -70,99 +73,6 @@ export function manhattanDistance(from: { x: number; y: number }, to: { x: numbe
return Math.abs(to.x - from.x) + Math.abs(to.y - from.y)
}
/**
* 오버레이 커 좌표 계산.
*
* 케어렛 앵커(또는 앵커 없음 → 커서)는 앵커 아래에 붙이는 것이 기본 — IME 후보창과
* 같은 관례다. 아래 공간이 없으면 위로 뒤집는다.
* 요소 앵커(케어렛을 못 얻어 elementRect 로 폴백한 경우)는 요소 "바깥" 에 붙인다 —
* 아래/위/오른쪽/왼쪽 순으로 작업영역에 들어맞는 첫 방향을 쓴다.
* 마지막으로 항상 작업영역 안으로 클램프한다.
*/
export function anchorFloatingPanel(
anchor: UiRect | null,
anchorKind: AnchorKind,
cursor: { x: number; y: number },
size: { width: number; height: number },
workArea: UiRect,
gap = 6
): { x: number; y: number } {
if (anchorKind === 'element' && anchor && anchor.width >= 0 && anchor.height >= 0) {
return anchorOutsideElement(anchor, size, workArea, gap)
}
const rect: UiRect =
anchor && anchor.width >= 0 && anchor.height >= 0
? anchor
: { x: cursor.x, y: cursor.y, width: 0, height: 0 }
const anchorBottom = rect.y + Math.max(rect.height, 0)
const desiredY = anchorBottom + gap
const fitsBelow = desiredY + size.height <= workArea.y + workArea.height
const above = rect.y - gap - size.height
const y = fitsBelow ? desiredY : above >= workArea.y ? above : desiredY
return {
x: clamp(rect.x, workArea.x, workArea.x + workArea.width - size.width),
y: clamp(y, workArea.y, workArea.y + workArea.height - size.height)
}
}
function fitsInWorkArea(
x: number,
y: number,
size: { width: number; height: number },
workArea: UiRect
): boolean {
return (
x >= workArea.x &&
y >= workArea.y &&
x + size.width <= workArea.x + workArea.width &&
y + size.height <= workArea.y + workArea.height
)
}
/**
* 요소 앵커를 요소 "바깥" 에 배치한다.
*
* 케어렛을 못 얻어 elementRect(입력창 전체)로 폴백했을 때, 기존 "앵커 아래/위" 규칙을
* 그대로 쓰면 큰/여러 줄 요소에서는 텍스트와 멀리 떨어지거나 텍스트 위에 겹친다
* (실측: 2·3번째 생성에서 패널이 튐). 아래→위→오른쪽→왼쪽 순으로 작업영역에
* 맞는 첫 방향을 쓰고, 전부 안 맞으면 요소 안쪽 우하단 모서리로 물러난다.
*/
function anchorOutsideElement(
element: UiRect,
size: { width: number; height: number },
workArea: UiRect,
gap: number
): { x: number; y: number } {
const candidates: Array<{ x: number; y: number }> = [
{ x: element.x, y: element.y + element.height + gap }, // 아래
{ x: element.x, y: element.y - gap - size.height }, // 위
{ x: element.x + element.width + gap, y: element.y }, // 오른쪽
{ x: element.x - gap - size.width, y: element.y } // 왼쪽
]
for (const candidate of candidates) {
if (fitsInWorkArea(candidate.x, candidate.y, size, workArea)) return candidate
}
const fallback = {
x: element.x + element.width - size.width - gap,
y: element.y + element.height - size.height - gap
}
return {
x: clamp(fallback.x, workArea.x, workArea.x + workArea.width - size.width),
y: clamp(fallback.y, workArea.y, workArea.y + workArea.height - size.height)
}
}
function clamp(value: number, min: number, max: number): number {
if (max < min) return min
return Math.max(min, Math.min(value, max))
}
// ============================================================
// 키스트로크 분류 (내용이 아니라 '종류'만 남긴다)
// ============================================================
@ -224,7 +134,8 @@ export function isWordBoundaryKey(keyClass: KeyStrokeClass): boolean {
* 단어 수.
*
* 공백 분리 토큰 중 문자/숫자/CJK 를 포함한 것만 센다.
* (중국어처럼 공백이 없는 언어는 과소 집계된다 — GAP-SUGGEST-03 에 기록)
* (중국어처럼 공백이 없는 언어는 과소 집계된다 — 활동 통계 전용. 학습 필터는
* text-segmentation.ts 의 countLexicalWords 로 센다)
*/
export function countWords(text: string): number {
if (!text) return 0
@ -348,8 +259,9 @@ export interface FocusSnapshot {
textSource: FocusTextSource
text: string
caretOffset: number | null
caretRect: UiRect | null
elementRect: UiRect | null
/** 물리 픽셀 (UIA 는 DPI-aware 로 동작한다) — 배치 전에 ScreenGeometryPort 로 DIP 변환 */
caretRect: PhysicalRect | null
elementRect: PhysicalRect | null
windowTitle: string | null
appName: string | null
processId: number | null
@ -617,6 +529,18 @@ export function matchesSessionPrefix(generatedPrefix: string, currentPrefix: str
return current.length === generated.length && current.slice(0, -1) === generated.slice(0, -1)
}
/**
* 진행 중인 생성(아직 후보 없음)을 계속 둘지 결정한다 — 세션 규칙과 같은 기준이다.
*
* 결과는 요청한 접두로 게시되고, 게시된 세션은 성장을 허용하지 않는다(matchesSessionPrefix).
* 그래서 요청 뒤 사용자가 몇 글자라도 더 쳤다면 결과는 도착하자마자 stale 로 닫힌다 —
* 깜빡임 · 버려지는 모델/채우기 요청 · 옛 접두용 문장 수락을 막으려면 그 즉시 취소한다.
* IME 마지막 글자 조합 변화만 예외다.
*/
export function decideInFlightGeneration(requestedPrefix: string, currentPrefix: string): 'keep' | 'stale' {
return matchesSessionPrefix(requestedPrefix, currentPrefix) ? 'keep' : 'stale'
}
/**
* 표시 중인 제안을 계속 둘지, 새로 만들지, 즉시 버릴지 결정한다.
*
@ -675,6 +599,14 @@ export interface SuggestionStepInput {
modelWarm: boolean
/** 직전 워밍업이 실패해 재시도를 잠시 미루는 중인가 */
warmUpBackoff: boolean
/**
* 모델 서버를 실제로 준비하는 작업(워밍업 루프)이 진행 중인가.
*
* 서버가 없을 때(model-unavailable) "준비 중" 은 이 작업이 있을 때만 보여준다 — 아무것도
* 준비하지 않는데 스피너를 띄우면 끝나지 않는 "준비 중" 이 옛 캐럿 위에 20초씩 반복된다
* (Ollama 미설치/중지). 생략하면 예전처럼 "준비 중" 을 보여준다(호환). 서비스는 항상 넘긴다.
*/
modelStarting?: boolean
}
/**
@ -723,7 +655,8 @@ export function decideSuggestionStep(input: SuggestionStepInput): SuggestionStep
action: 'settle',
reason: 'model-unavailable',
tryLocalMemory: localMemoryAllowed,
then: 'show-warming'
// 준비 작업이 없으면 기능은 꺼진 것처럼 조용해야 한다 (설정 문구: "제안이 꺼진 상태로 동작").
then: input.modelStarting === false ? 'record' : 'show-warming'
}
}
return { action: 'settle', reason: decision.reason, tryLocalMemory: false, then: 'record' }
@ -816,6 +749,21 @@ export const LEARNING_EXCLUDED_APPS: readonly string[] = Object.freeze([
'Agent Switchboard'
])
/**
* 제안 이력에 입력 문맥(접두) 원문을 남겨도 되는가.
*
* 접두는 사용자가 친 글 그 자체다 — 학습 동의(inputLearnTypedText)가 있고, 학습 제외 앱
* (터미널·에디터·에이전트 허브)이 아닐 때만 저장한다. 텔레메트리 학습과 같은 규칙을 쓴다.
* 그 밖에는 원문 대신 빈 문자열을 남겨 이력 개수·수락률 통계만 유지한다.
*/
export function shouldPersistSuggestionPrefix(input: {
learnTypedText: boolean
appName: string | null
}): boolean {
if (!input.learnTypedText) return false
return !(input.appName && isAppExcluded(input.appName, LEARNING_EXCLUDED_APPS))
}
/** 공백을 뺀 글자 중 문자(모든 언어)가 이 비율 이상이어야 문장으로 본다. */
const MIN_LETTER_RATIO = 0.6
@ -1119,7 +1067,8 @@ export function extractPhrases(text: string, maxPhrases = 4, minChars = 6): stri
const phrase = segment.replace(/\s+/gu, ' ').trim()
if (phrase.length < minChars) continue
if (phrase.length > SUGGESTION_MAX_OUTPUT_CHARS) continue
if (countWords(phrase) < 2) continue
// 띄어쓰기 없는 문자 체계(중·일·태)도 문구로 인정한다 — 공백 단어 수로는 항상 1이다.
if (countLexicalWords(phrase) < 2) continue
if (out.includes(phrase)) continue
out.push(phrase)
if (out.length >= maxPhrases) break
@ -1170,6 +1119,8 @@ export interface SuggestionCandidate {
text: string
/** 0 = 최상위 */
rank: number
/** 접두와의 이음새 — 생략하면 'separate' (joinSuggestion 참고) */
joint?: SuggestionJoint
}
/** 현재 제안이 어느 로컬 근거에서 나왔는지 보여 주는 비식별 설명. */
@ -1200,8 +1151,8 @@ export interface SuggestionState {
* UI 가 "4–6 / 9" 같은 진행률을 보여주는 근거.
*/
targetTotal: number
/** 앵커 rect (케어렛 → 요소 → 마우스 폴백은 메인이 계산) */
anchor: UiRect | null
/** 앵커 rect — 물리 픽셀 (케어렛 → 요소 → 마우스 폴백은 메인이 계산, DIP 변환은 창 배치 한 곳에서) */
anchor: PhysicalRect | null
/** 앵커가 케어렛인지 요소 전체인지 — 배치 전략(caret vs element)을 결정한다 */
anchorKind: AnchorKind
appName: string | null