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

@ -22,6 +22,7 @@ import {
decideSuggestionRefresh,
emptyActivityBucket,
endsSentence,
extendsPrefix,
extractPhrases,
isAppExcluded,
isWordBoundaryKey,
@ -37,9 +38,57 @@ import {
type PersonalPhrase,
type SuggestionPolicyInput
} from '@d3ro/core/input-intelligence'
import {
LEARNING_EXCLUDED_APPS,
TERMINAL_APPS,
isAppExcluded,
isLearnablePhrase,
withoutPlaceholderText,
emptyFocusSnapshot
} from '@d3ro/core/input-intelligence'
const NO_MODS = { ctrl: false, alt: false, shift: false, meta: false }
describe('개인 코퍼스 학습 규칙', () => {
it('실측된 터미널 상태줄·타임스탬프는 문장으로 치지 않는다', () => {
for (const junk of [
'◑ OPUS 5',
'00 ◷9',
'5 medium │ CTX ▕░░░░░░░░░░░░▏ 0% 0K/1M',
'0 tokens ─────────────',
'5 분 5',
'7 분 12'
]) {
expect(isLearnablePhrase(junk), junk).toBe(false)
}
})
it('일상 문장은 언어와 무관하게 통과한다', () => {
for (const prose of ['하람이랑 열심히 놀고 있어요', 'Thank you', '달빛에 비치는 캐릭터', 'api 목록에도 안']) {
expect(isLearnablePhrase(prose), prose).toBe(true)
}
})
it('터미널·에디터·코딩 에이전트는 학습에서 빠지고, 터미널만 제안에서도 빠진다', () => {
expect(isAppExcluded('WindowsTerminal.exe', LEARNING_EXCLUDED_APPS)).toBe(true)
expect(isAppExcluded('Agent Switchboard.exe', LEARNING_EXCLUDED_APPS)).toBe(true)
expect(isAppExcluded('Code.exe', LEARNING_EXCLUDED_APPS)).toBe(true)
expect(isAppExcluded('KakaoTalk.exe', LEARNING_EXCLUDED_APPS)).toBe(false)
expect(isAppExcluded('WindowsTerminal.exe', TERMINAL_APPS)).toBe(true)
expect(isAppExcluded('Agent Switchboard.exe', TERMINAL_APPS)).toBe(false)
})
it('입력창 이름과 같은 텍스트(안내 문구)는 빈 칸으로 본다', () => {
const base = { ...emptyFocusSnapshot('-', 0), available: true, isEditable: true, caretOffset: 6 }
const placeholder = withoutPlaceholderText({ ...base, controlName: '메시지 입력', text: '메시지 입력' })
expect(placeholder.text).toBe('')
expect(placeholder.caretOffset).toBeNull()
const typed = withoutPlaceholderText({ ...base, controlName: '메시지 입력', text: '안녕하세요' })
expect(typed.text).toBe('안녕하세요')
})
})
describe('classifyKeyStroke', () => {
it('문자/숫자/기능 키를 종류로 나눈다', () => {
expect(classifyKeyStroke(0x41, NO_MODS)).toBe('letter') // A
@ -155,6 +204,8 @@ describe('decideSuggestion', () => {
isEditable: true,
appName: 'chrome.exe',
excludedApps: [],
editedSinceFocus: true,
typedRecently: true,
prefix: '오늘 회의에서 논의한 내용을 정리해서',
idleMs: SUGGESTION_DEFAULTS.triggerDelayMs + 50,
triggerDelayMs: SUGGESTION_DEFAULTS.triggerDelayMs,
@ -258,6 +309,25 @@ describe('decideSuggestion', () => {
reason: 'already-visible'
})
})
it('포커스만 옮겨 왔을 뿐(편집 없음) 이면 지운다 (마우스 클릭만으로 옛 텍스트가 제안되던 문제)', () => {
// 실측: YouTube 검색창(이미 19자 옛 검색어가 있는)을 클릭만 했는데 제안이 떴다.
expect(decideSuggestion(policy({ editedSinceFocus: false }))).toEqual({
action: 'clear',
reason: 'not-typing'
})
})
it('편집은 했지만 최근에 실제로 타이핑한 적이 없으면 지운다', () => {
expect(decideSuggestion(policy({ typedRecently: false }))).toEqual({
action: 'clear',
reason: 'not-typing'
})
})
it('편집도 했고 최근 타이핑도 있으면 통과한다', () => {
expect(decideSuggestion(policy({ editedSinceFocus: true, typedRecently: true })).action).toBe('request')
})
})
describe('표시 중 제안 재생성 정책', () => {
@ -274,6 +344,31 @@ describe('표시 중 제안 재생성 정책', () => {
it('생성 접두의 앞부분이 바뀌면 stale 로 처리한다', () => {
expect(decideSuggestionRefresh('회의 결과를 공유합니다', '프로젝트 결과를 공유합니다')).toBe('stale')
})
it('IME 조합으로 마지막 글자만 바뀌면 stale 이 아니라 keep 이다', () => {
// 생성 시점엔 "하" 로 끝났는데, 조합이 이어져 지금은 "한" 으로 끝난 경우.
expect(decideSuggestionRefresh('회의록을 정리하고 공유하려고 하', '회의록을 정리하고 공유하려고 한')).toBe(
'keep'
)
})
})
describe('extendsPrefix — IME 마지막 글자 조합 보정', () => {
it('마지막 글자만 조합 중 문자로 바뀌어도 연속 확장으로 본다', () => {
expect(extendsPrefix('회의록을 정리하고 공유하려고 하', '회의록을 정리하고 공유하려고 한')).toBe(true)
})
it('마지막 글자를 빼도 접두가 다르면 확장이 아니다', () => {
expect(extendsPrefix('회의 결과를 공유합니다', '프로젝트 결과를 공유합니다')).toBe(false)
})
it('완전히 같은 접두는 확장이다', () => {
expect(extendsPrefix('회의 결과를 공유합니다', '회의 결과를 공유합니다')).toBe(true)
})
it('접두 뒤로 자란 경우도 확장이다', () => {
expect(extendsPrefix('회의 결과를 공유합니다', '회의 결과를 공유합니다 내일')).toBe(true)
})
})
describe('isAppExcluded', () => {
@ -336,14 +431,21 @@ describe('anchorFloatingPanel', () => {
const workArea = { x: 0, y: 0, width: 1920, height: 1080 }
const size = { width: 460, height: 96 }
it('케어 아래에 붙인다', () => {
const position = anchorFloatingPanel({ x: 400, y: 300, width: 2, height: 20 }, { x: 0, y: 0 }, size, workArea)
it('케어 앵커는 아래에 붙인다', () => {
const position = anchorFloatingPanel(
{ x: 400, y: 300, width: 2, height: 20 },
'caret',
{ x: 0, y: 0 },
size,
workArea
)
expect(position).toEqual({ x: 400, y: 326 })
})
it('아래 공간이 없으면 위로 뒤집는다', () => {
it('케어 앵커는 아래 공간이 없으면 위로 뒤집는다', () => {
const position = anchorFloatingPanel(
{ x: 400, y: 1000, width: 2, height: 20 },
'caret',
{ x: 0, y: 0 },
size,
workArea
@ -351,9 +453,10 @@ describe('anchorFloatingPanel', () => {
expect(position.y).toBe(1000 - 6 - 96)
})
it('작업영역 밖으로 나가지 않는다', () => {
it('케어 앵커는 작업영역 밖으로 나가지 않는다', () => {
const position = anchorFloatingPanel(
{ x: 1900, y: 10, width: 2, height: 20 },
'caret',
{ x: 0, y: 0 },
size,
workArea
@ -363,9 +466,44 @@ describe('anchorFloatingPanel', () => {
})
it('앵커가 없으면 커서를 쓴다', () => {
const position = anchorFloatingPanel(null, { x: 200, y: 500 }, size, workArea)
const position = anchorFloatingPanel(null, null, { x: 200, y: 500 }, size, workArea)
expect(position).toEqual({ x: 200, y: 506 })
})
describe('요소 앵커 (케어렛을 못 얻어 elementRect 로 폴백한 경우)', () => {
it('아래에 맞으면 요소 바깥 아래에 붙인다', () => {
const element = { x: 400, y: 300, width: 300, height: 40 }
const position = anchorFloatingPanel(element, 'element', { x: 0, y: 0 }, size, workArea)
expect(position).toEqual({ x: 400, y: 300 + 40 + 6 })
})
it('아래가 안 맞고 위가 맞으면 요소 바깥 위에 붙인다', () => {
const element = { x: 400, y: 1080 - 50, width: 300, height: 40 }
const position = anchorFloatingPanel(element, 'element', { x: 0, y: 0 }, size, workArea)
expect(position).toEqual({ x: 400, y: element.y - 6 - size.height })
})
it('위아래 다 안 맞으면 오른쪽 바깥에 붙인다', () => {
const element = { x: 0, y: 0, width: 1400, height: 1076 }
const position = anchorFloatingPanel(element, 'element', { x: 0, y: 0 }, size, workArea)
expect(position).toEqual({ x: element.x + element.width + 6, y: element.y })
})
it('위아래오른쪽 다 안 맞으면 왼쪽 바깥에 붙인다', () => {
const element = { x: 1820, y: 0, width: 100, height: 1080 }
const position = anchorFloatingPanel(element, 'element', { x: 0, y: 0 }, size, workArea)
expect(position).toEqual({ x: element.x - 6 - size.width, y: element.y })
})
it('네 방향 다 안 맞으면 요소 안쪽 우하단 모서리로 물러난다', () => {
const element = { x: 0, y: 0, width: 1920, height: 1080 }
const position = anchorFloatingPanel(element, 'element', { x: 0, y: 0 }, size, workArea)
expect(position).toEqual({
x: element.x + element.width - size.width - 6,
y: element.y + element.height - size.height - 6
})
})
})
})
describe('마우스 이동', () => {