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

@ -0,0 +1,76 @@
import { describe, expect, it } from 'vitest'
import { decideSuggestion, extractTerminalPromptPrefix, isTerminalApp } from '../src/input-intelligence'
describe('terminal prompt extraction', () => {
it('takes the Claude Code input line and ignores the status line below it', () => {
const screen = [
'● Wrote 3 files',
'',
'╭──────────────────────────────────────╮',
'│ > 오늘 회의 내용을 정리해서 │',
'╰──────────────────────────────────────╯',
' ◑ OPUS 5 · 5 medium │ CTX ▕░░▏',
].join('\n')
expect(extractTerminalPromptPrefix(screen)).toBe('오늘 회의 내용을 정리해서')
})
it('joins wrapped continuation lines of a long prompt', () => {
const screen = ['──────────', '> 긴 문장을 쓰다 보면', ' 줄이 바뀌어서 이어진다', '──────────', ' ? for shortcuts'].join('\n')
expect(extractTerminalPromptPrefix(screen)).toBe('긴 문장을 쓰다 보면 줄이 바뀌어서 이어진다')
})
it('reads shell prompts', () => {
expect(extractTerminalPromptPrefix('PS C:\\Users\\me> git commit -m "고친')).toBe('git commit -m "고친')
expect(extractTerminalPromptPrefix('C:\\work> dir')).toBe('dir')
expect(extractTerminalPromptPrefix('me@host:~/app$ npm run')).toBe('npm run')
expect(extractTerminalPromptPrefix('❯ cargo build')).toBe('cargo build')
})
it('returns null when only output is on screen, and does not mistake percentages for prompts', () => {
expect(extractTerminalPromptPrefix('Compiling...\n100% 완료\nDone in 3s')).toBeNull()
expect(extractTerminalPromptPrefix('')).toBeNull()
})
it('an empty prompt yields an empty prefix', () => {
expect(extractTerminalPromptPrefix('output\n> ')).toBe('')
})
it('recognises terminal apps', () => {
expect(isTerminalApp('WindowsTerminal.exe')).toBe(true)
expect(isTerminalApp('KakaoTalk.exe')).toBe(false)
expect(isTerminalApp(null)).toBe(false)
})
})
describe('a visible suggestion survives a typing pause', () => {
const base = {
enabled: true,
modelAvailable: true,
composing: false,
hasSelection: false,
isPassword: false,
isEditable: true,
appName: 'KakaoTalk.exe',
excludedApps: [],
editedSinceFocus: false,
typedRecently: false,
prefix: '내일 몇 시에 볼까',
idleMs: 5000,
triggerDelayMs: 100,
minPrefixChars: 4,
sinceLastRequestMs: 60_000,
minIntervalMs: 5000,
requestsThisMinute: 0,
maxRequestsPerMinute: 6,
requestsToday: 0,
dailyBudget: 500,
}
it('keeps the overlay while the user reads and picks a candidate', () => {
expect(decideSuggestion({ ...base, overlayVisible: true })).toEqual({ action: 'skip', reason: 'already-visible' })
})
it('still refuses to start a new suggestion without typing', () => {
expect(decideSuggestion({ ...base, overlayVisible: false })).toEqual({ action: 'clear', reason: 'not-typing' })
})
})

View file

@ -525,7 +525,13 @@ export function decideSuggestion(input: SuggestionPolicyInput): SuggestionDecisi
// 유휴 판정이 키보드 기준이라, 필드에 이미 차 있던 텍스트로 클릭만 해도 (마지막
// 키 입력이 오래전이라) idleMs 조건을 통과해 제안이 뜨던 문제(실측: YouTube 검색창
// 클릭만 했는데 옛 검색어로 제안이 뜸).
//
// 단, 이미 떠 있는 제안은 지우지 않는다. 사용자가 타이핑을 멈추는 순간은 바로
// 후보를 읽고 고르는 순간이다 — 여기서 지우면 클릭·단축키 수락이 받을 후보가
// 없어 "골라도 안 들어가는" 상태가 된다(실측 2026-09-27: 후보 표시 2초 뒤
// not-typing 으로 닫힘). 새 요청만 막는다.
if (!input.editedSinceFocus || !input.typedRecently) {
if (input.overlayVisible) return { action: 'skip', reason: 'already-visible' }
return { action: 'clear', reason: 'not-typing' }
}
@ -626,7 +632,7 @@ export function isAppExcluded(appName: string, excludedApps: readonly string[]):
/**
* 터미널 — 화면 버퍼가 곧 "입력창" 으로 읽혀 상태줄·명령·출력이 친 글로 잡힌다
* (실측: Claude Code 상태줄 `◑ OPUS 5`, `5 medium │ CTX ▕░░▏` 가 학습됨).
* 제안도 셸 프롬프트 위에 뜨므로 여기서는 제안과 학습을 모두 하지 않는다.
* 그래서 학습에서는 뺀다. 제안은 화면에서 입력 줄만 골라 쓴다(extractTerminalPromptPrefix).
*/
export const TERMINAL_APPS: readonly string[] = Object.freeze([
'WindowsTerminal',
@ -644,6 +650,51 @@ export const TERMINAL_APPS: readonly string[] = Object.freeze([
'Warp'
])
export function isTerminalApp(appName: string | null | undefined): boolean {
return !!appName && isAppExcluded(appName, TERMINAL_APPS)
}
/**
* 입력 줄의 시작 표시: Claude Code/Codex `>`·`›`, starship `❯`, PowerShell `PS …>`, cmd `C:\…>`,
* POSIX `$ # %`(단독이거나 앞에 경로·호스트 표지가 있을 때만 — 출력의 `100% 완료` 를 입력으로 보지 않는다).
*/
const TERMINAL_PROMPT_PATTERN =
/^(?:PS [^>\n]{1,260}>|[A-Za-z]:\\[^>\n]{0,260}>|(?:[^\s]*[@:~/\\][^\s]*)?[$#%]|>|›|❯|»)[ \u00A0]?(.*)$/u
/** 테두리·상자 줄(─━═╭╮╰╯ 등)이나 비어 있는 줄에서 입력 영역이 끝난다. */
const TERMINAL_BORDER_PATTERN = /^[\s─━═┄┈╌╍╭╮╰╯┌┐└┘├┤┬┴┼│┃║▔▁▏▕-]*$/u
const TERMINAL_EDGE_PATTERN = /^[\s│┃║]+|[\s│┃║]+$/gu
const TERMINAL_SCAN_LINES = 40
/**
* 터미널 화면 텍스트에서 "지금 치고 있는 입력" 을 뽑는다.
*
* 문서형 터미널은 캐럿 위치를 주지 않고 화면 전체를 돌려준다 — 마지막 줄은 대개
* 상태줄이다. 아래에서 위로 가장 가까운 프롬프트 줄을 찾고, 그 아래로 테두리/빈 줄이
* 나올 때까지의 이어진 줄(줄바꿈된 긴 입력)을 붙인다. 프롬프트를 못 찾으면 null —
* 제안하지 않는다(출력·상태줄로 제안을 만들지 않는다).
*/
export function extractTerminalPromptPrefix(screen: string | null | undefined): string | null {
if (!screen) return null
const lines = screen.replace(/\r/g, '').split('\n')
const start = Math.max(0, lines.length - TERMINAL_SCAN_LINES)
for (let i = lines.length - 1; i >= start; i--) {
const line = lines[i].replace(TERMINAL_EDGE_PATTERN, '')
const match = TERMINAL_PROMPT_PATTERN.exec(line)
if (!match) continue
const parts = [match[1].trimEnd()]
for (let j = i + 1; j < lines.length; j++) {
const raw = lines[j]
if (TERMINAL_BORDER_PATTERN.test(raw)) break
const continuation = raw.replace(TERMINAL_EDGE_PATTERN, '')
// 상태줄 기호(◑ ▕░ 등)가 섞인 줄은 입력이 아니다.
if (!continuation || NON_PROSE_GLYPH_PATTERN.test(continuation)) break
parts.push(continuation)
}
return parts.join(' ').replace(/\s+/gu, ' ').trimStart()
}
return null
}
/**
* 학습에서 빼는 앱 — 터미널 + 코드 에디터 + 코딩 에이전트 허브.
*