fix(suggestion): place overlay in DIPs, size it to content, and harden suggestion sessions
This commit is contained in:
parent
75e053d72f
commit
9e5b94ced2
18 changed files with 2178 additions and 558 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue