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

@ -47,6 +47,7 @@ const context = {
fullText: PREFIX,
caretOffset: PREFIX.length,
anchor: null,
anchorKind: null,
isPassword: false,
isEditable: true,
isComposing: false,
@ -55,7 +56,9 @@ const context = {
appName: 'notepad.exe',
windowTitle: 'notes',
idleMs: 1000,
capturedAt: Date.now()
capturedAt: Date.now(),
editedSinceFocus: true,
typedRecently: true
}
interface InternalSuggestionService {
@ -112,7 +115,7 @@ afterEach(async () => {
})
describe('SuggestionService warm-up', () => {
it('동시 warm-up 호출을 하나의 1토큰 요청으로 합치고 2분만 유지한다', async () => {
it('동시 warm-up 호출을 하나의 1토큰 요청으로 합치고 10분간 유지한다', async () => {
localLlm.streamGenerate.mockImplementation(async function* () {
yield 'ok'
})
@ -126,11 +129,63 @@ describe('SuggestionService warm-up', () => {
expect(localLlm.streamGenerate).toHaveBeenCalledTimes(1)
expect(localLlm.streamGenerate).toHaveBeenCalledWith(
'hi',
expect.objectContaining({ maxTokens: 1, keepAlive: '2m' })
expect.objectContaining({ maxTokens: 1, keepAlive: '10m' })
)
expect(warmingStates).toEqual([true, false])
})
it('콜드 모델(워밍업 전)에서는 요청 결정이어도 생성 대신 워밍업을 트리거한다', async () => {
localLlm.streamGenerate.mockImplementation(async function* () {
yield 'ok'
})
const { getSuggestionService } = await import('../../../src/main/services/SuggestionService')
const service = getSuggestionService()
const internal = service as unknown as InternalSuggestionService & {
_warmUpPromise: Promise<void> | null
}
const generateSpy = vi.spyOn(internal, '_generate')
service.handleTypingContext({ ...context, idleMs: 1000 })
expect(generateSpy).not.toHaveBeenCalled()
expect(localLlm.streamGenerate).toHaveBeenCalledWith('hi', expect.objectContaining({ maxTokens: 1 }))
const state = service.getState()
expect(state.warmingUp).toBe(true)
expect(state.lastSkipReason).toBe('model-unavailable')
await internal._warmUpPromise
})
it('dismiss(stale)는 진행 중인 워밍업을 취소하지 않는다', async () => {
let resolveChunk: (() => void) | null = null
localLlm.streamGenerate.mockImplementation((_text: string, options: { signal?: AbortSignal }) => {
return (async function* () {
await new Promise<void>((resolve, reject) => {
resolveChunk = resolve
options.signal?.addEventListener('abort', () => reject(new Error('aborted')), { once: true })
})
yield 'ok'
})()
})
const { getSuggestionService } = await import('../../../src/main/services/SuggestionService')
const service = getSuggestionService()
const internal = service as unknown as InternalSuggestionService & {
_warmUpAbort: AbortController | null
}
const warmUpPromise = service.warmUp()
await Promise.resolve()
expect(internal._warmUpAbort?.signal.aborted).toBe(false)
service.dismiss('stale')
expect(internal._warmUpAbort?.signal.aborted).toBe(false)
resolveChunk?.()
await warmUpPromise
expect(localLlm.streamGenerate).toHaveBeenCalledTimes(1)
})
it('비활성화하면 가용성 재시도 타이머와 warm-up을 취소한다', async () => {
vi.useFakeTimers()
localLlm.isAvailable.mockReturnValue(false)
@ -224,10 +279,13 @@ describe('SuggestionService warm-up', () => {
await (service as unknown as InternalSuggestionService)._generate(PREFIX, context, 3, 240)
const published = states.find((state) => state.candidates.length > 0)
expect(published).toMatchObject({ generating: false, partialText: null })
// partialText 는 후보 공개와 함께 비워지지만, generating 은 계속 true 로 남는다 —
// 첫 후보 공개 뒤 채우기 루프가 백그라운드에서 나머지(최대 12개)를 마저
// 청하는 중이라는 신호다(설계).
expect(published).toMatchObject({ generating: true, partialText: null })
})
it('stale 취소는 실패 쿨다운을 올리거나 즉시 재시작하지 않는다', async () => {
it('stale 취소는 실패 쿨다운을 올리거나 즉시 재시작하지 않고, 보여줄 후보가 없으면 오버레이를 지운다', async () => {
localLlm.streamGenerate.mockImplementation((_text: string, options: { signal?: AbortSignal }) =>
waitForAbort(options.signal)
)
@ -245,7 +303,81 @@ describe('SuggestionService warm-up', () => {
expect(internal._consecutiveFailures).toBe(0)
expect(internal._cooldownUntil).toBe(0)
expect(localLlm.streamGenerate).toHaveBeenCalledTimes(1)
expect(cleared).toEqual([])
// 스피너만 뜬 채로 남지 않도록, 후보가 없는 stale 취소는 오버레이를 닫는다.
expect(cleared).toEqual(['stale'])
})
it('stale 취소는 속도 제한 예산을 환급해 다음 요청이 rate-limited 되지 않는다', async () => {
localLlm.streamGenerate.mockImplementation((_text: string, options: { signal?: AbortSignal }) =>
waitForAbort(options.signal)
)
const { getSuggestionService } = await import('../../../src/main/services/SuggestionService')
const service = getSuggestionService()
const internal = service as unknown as InternalSuggestionService & {
_minuteCount: number
_dayCount: number
_prevRequestAt: number
_lastSkipReason: string | null
}
// 분당 카운터 창을 먼저 굳힌다 — 그렇지 않으면 이 인스턴스의 첫 getState() 호출
// (_generate 내부의 emit('updated', ...) 에서 일어난다) 이 창을 "지금" 으로
// 다시 잡으며 방금 늘린 카운트를 0으로 되돌린다 (실제 흐름에선 handleTypingContext
// 가 먼저 창을 굳혀 두므로 일어나지 않는 순서 문제).
service.getState()
const generation = internal._generate(PREFIX, context, 3, 240)
await Promise.resolve()
expect(internal._minuteCount).toBe(1)
const requestedAt = internal._lastRequestAt
expect(requestedAt).toBeGreaterThan(0)
internal._abortStaleGeneration('완전히 다른 문맥으로 바뀐 입력입니다')
await generation
// 취소된 요청이 쓴 예산이 되돌아간다.
expect(internal._minuteCount).toBe(0)
expect(internal._lastRequestAt).toBe(internal._prevRequestAt)
expect(internal._lastRequestAt).toBeLessThan(requestedAt)
// 되돌아간 예산으로 바로 다음 요청은 rate-limited 로 막히지 않는다.
service.handleTypingContext({ ...context, idleMs: 1000 })
expect(internal._lastSkipReason).not.toBe('rate-limited')
})
it('pageNext는 다음 페이지 첫 항목으로, 후보가 없는 페이지면 그대로 둔다', async () => {
const { getSuggestionService } = await import('../../../src/main/services/SuggestionService')
const service = getSuggestionService()
const internal = service as unknown as { _candidates: Array<{ text: string; rank: number }> }
internal._candidates = Array.from({ length: 5 }, (_v, i) => ({ text: `후보${i}`, rank: i }))
expect(service.getState().activeIndex).toBe(0)
service.pageNext()
expect(service.getState().activeIndex).toBe(3)
service.pageNext()
// 5개뿐이라 다음 페이지가 없다 — 그대로 둔다.
expect(service.getState().activeIndex).toBe(3)
service.pagePrev()
expect(service.getState().activeIndex).toBe(0)
service.pagePrev()
expect(service.getState().activeIndex).toBe(0)
})
it('next/previous는 페이지와 무관하게 전체 후보를 가로질러 순환한다', async () => {
const { getSuggestionService } = await import('../../../src/main/services/SuggestionService')
const service = getSuggestionService()
const internal = service as unknown as { _candidates: Array<{ text: string; rank: number }> }
internal._candidates = Array.from({ length: 4 }, (_v, i) => ({ text: `후보${i}`, rank: i }))
service.next()
service.next()
service.next()
expect(service.getState().activeIndex).toBe(3)
service.next()
expect(service.getState().activeIndex).toBe(0)
service.previous()
expect(service.getState().activeIndex).toBe(3)
})
it('watchdog는 실제 요청 signal을 abort하고 세대를 무효화한다', async () => {
@ -272,3 +404,165 @@ describe('SuggestionService warm-up', () => {
expect(cleared).toEqual([])
})
})
describe('SuggestionService 세션 채우기 (최대 12개, 순차 · 페이지)', () => {
it('첫 요청은 1개만 청하고, 성공하면 채우기 루프가 하나씩 최대 12개까지 채운다', async () => {
// 접두/확장 중복 판정 때문에 숫자 접미사(1, 10, 11…)는 서로를 중복으로 오판한다
// ("이어지는 문장 1" 이 "이어지는 문장 10" 의 접두이므로) — 서로소인 글자를 쓴다.
const LETTERS = 'ABCDEFGHIJKL'
let call = 0
localLlm.streamGenerate.mockImplementation(async function* () {
const letter = LETTERS[call % LETTERS.length]
call += 1
yield `이어지는 문장 ${letter}`
})
const { getSuggestionService } = await import('../../../src/main/services/SuggestionService')
const service = getSuggestionService()
const internal = service as unknown as InternalSuggestionService
await internal._generate(PREFIX, context, 3, 240)
// 세션의 첫 요청은 정확히 1개만 청한다 (한꺼번에 여러 개를 청하면 느리다 — 사용자 요청).
expect(localLlm.streamGenerate).toHaveBeenCalledWith(
expect.stringContaining('1개'),
expect.anything()
)
await vi.waitFor(() => {
expect(service.getState().candidates).toHaveLength(12)
})
expect(service.getState().generating).toBe(false)
expect(service.getState().targetTotal).toBe(12)
expect(localLlm.streamGenerate).toHaveBeenCalledTimes(12)
})
it('연속 2번 새 후보가 없으면(전부 중복) 채우기를 멈춘다', async () => {
localLlm.streamGenerate.mockImplementation(async function* () {
yield '같은 문장입니다.'
})
const { getSuggestionService } = await import('../../../src/main/services/SuggestionService')
const service = getSuggestionService()
const internal = service as unknown as InternalSuggestionService
await internal._generate(PREFIX, context, 3, 240)
await vi.waitFor(() => {
expect(service.getState().generating).toBe(false)
})
expect(service.getState().candidates).toHaveLength(1)
// 첫 요청 1번 + 중복으로 끝난 채우기 시도 2번 = 3번.
expect(localLlm.streamGenerate).toHaveBeenCalledTimes(3)
})
it('중복(완전 일치·접두/확장)은 건너뛰고 고유한 후보만 덧붙인다', async () => {
const sequence = ['같은 문장입니다.', '같은 문장입니다.', '다른 문장입니다.']
let call = 0
localLlm.streamGenerate.mockImplementation(async function* () {
const text = sequence[Math.min(call, sequence.length - 1)]
call += 1
yield text
})
const { getSuggestionService } = await import('../../../src/main/services/SuggestionService')
const service = getSuggestionService()
const internal = service as unknown as InternalSuggestionService
await internal._generate(PREFIX, context, 3, 240)
await vi.waitFor(() => {
expect(service.getState().generating).toBe(false)
})
expect(service.getState().candidates.map((c) => c.text)).toEqual([
'같은 문장입니다.',
'다른 문장입니다.'
])
})
it('dismiss는 진행 중인 채우기 요청을 취소하고 루프를 멈춘다', async () => {
let fillSignal: AbortSignal | undefined
let firstServed = false
localLlm.streamGenerate.mockImplementation((_text: string, options: { signal?: AbortSignal }) => {
if (!firstServed) {
firstServed = true
return (async function* () {
yield '첫 후보입니다.'
})()
}
fillSignal = options.signal
return waitForAbort(options.signal)
})
const { getSuggestionService } = await import('../../../src/main/services/SuggestionService')
const service = getSuggestionService()
const internal = service as unknown as InternalSuggestionService
await internal._generate(PREFIX, context, 3, 240)
await vi.waitFor(() => expect(fillSignal).toBeDefined())
expect(fillSignal?.aborted).toBe(false)
service.dismiss('dismissed')
expect(fillSignal?.aborted).toBe(true)
expect(service.getState().candidates).toHaveLength(0)
})
it('세션의 첫 요청만 분당/일일 예산을 쓴다 — 채우기 요청은 쓰지 않는다', async () => {
const LETTERS = 'ABCDEFGHIJKL'
let call = 0
localLlm.streamGenerate.mockImplementation(async function* () {
const letter = LETTERS[call % LETTERS.length]
call += 1
yield `문장 ${letter}`
})
const { getSuggestionService } = await import('../../../src/main/services/SuggestionService')
const service = getSuggestionService()
const internal = service as unknown as InternalSuggestionService & {
_minuteCount: number
_dayCount: number
}
// 분당 카운터 창을 먼저 굳힌다 (다른 테스트와 같은 이유 — 위 주석 참조).
service.getState()
await internal._generate(PREFIX, context, 3, 240)
await vi.waitFor(() => expect(service.getState().candidates).toHaveLength(12))
expect(internal._minuteCount).toBe(1)
expect(internal._dayCount).toBe(1)
})
it('세션이 떠 있는 동안 접두가 자라면(다음 문장 시작) 즉시 세션을 끝낸다', async () => {
localLlm.streamGenerate.mockImplementation(async function* () {
yield '첫 후보입니다.'
})
const { getSuggestionService } = await import('../../../src/main/services/SuggestionService')
const service = getSuggestionService()
const internal = service as unknown as InternalSuggestionService
const cleared: string[] = []
service.on('cleared', ({ reason }) => cleared.push(reason))
await internal._generate(PREFIX, context, 3, 240)
await vi.waitFor(() => expect(service.getState().visible).toBe(true))
service.handleTypingContext({ ...context, prefix: `${PREFIX} 추가로 입력했습니다`, idleMs: 1000 })
expect(service.getState().visible).toBe(false)
expect(cleared).toContain('stale')
})
it('세션이 떠 있는 동안 마지막 글자만 IME 조합으로 바뀌면 세션을 유지한다', async () => {
localLlm.streamGenerate.mockImplementation(async function* () {
yield '첫 후보입니다.'
})
const { getSuggestionService } = await import('../../../src/main/services/SuggestionService')
const service = getSuggestionService()
const internal = service as unknown as InternalSuggestionService
const cleared: string[] = []
service.on('cleared', ({ reason }) => cleared.push(reason))
await internal._generate(PREFIX, context, 3, 240)
await vi.waitFor(() => expect(service.getState().visible).toBe(true))
const mutatedLastChar = `${PREFIX.slice(0, -1)}요`
service.handleTypingContext({ ...context, prefix: mutatedLastChar, idleMs: 1000 })
expect(service.getState().visible).toBe(true)
expect(cleared).not.toContain('stale')
})
})