diff --git a/apps/desktop/src/main/services/SuggestionService.ts b/apps/desktop/src/main/services/SuggestionService.ts index fab3597..e9b25d5 100644 --- a/apps/desktop/src/main/services/SuggestionService.ts +++ b/apps/desktop/src/main/services/SuggestionService.ts @@ -24,7 +24,8 @@ import { parseSuggestionCandidates, sanitizeSuggestionLine, selectPhraseHints, - TERMINAL_APPS, + isTerminalApp, + extractTerminalPromptPrefix, type AnchorKind, type SuggestionCandidate, type SuggestionProvenance, @@ -335,7 +336,11 @@ class SuggestionService extends EventEmitter { * * 정책 판단 → (stale 정리) → 필요하면 생성. */ - handleTypingContext(context: TypingContext): void { + handleTypingContext(input: TypingContext): void { + // 터미널은 화면 전체가 읽힌다 — 입력 줄만 접두로 쓴다. 입력 줄이 없으면 빈 접두(제안 안 함). + const context: TypingContext = isTerminalApp(input.appName) + ? { ...input, prefix: extractTerminalPromptPrefix(input.fullText ?? input.prefix) ?? '' } + : input this._lastContext = context const now = Date.now() this._lastContextAt = now @@ -1247,7 +1252,11 @@ class SuggestionService extends EventEmitter { this._activeIndex = index } const candidate = this._candidates[this._activeIndex] - if (!candidate) return { ok: false, reason: 'already-visible' } + if (!candidate) { + // 창이 이미 닫힌 뒤의 클릭·단축키 — 조용히 끝나면 "골라도 안 들어간다" 의 원인이 안 보인다. + logger.info(`제안 수락 무시: 표시 중인 후보 없음 (index=${index ?? 'active'})`) + return { ok: false, reason: 'already-visible' } + } const text = candidate.text const appName = this._appName const windowTitle = this._windowTitle @@ -1277,6 +1286,7 @@ class SuggestionService extends EventEmitter { return { ok: false, reason: 'generation-failed' } } + logger.info(`제안 수락: ${text.length}자 삽입 (method=${method === 'keyboard' ? 'keyboard' : 'clipboard'}, app=${appName ?? '-'})`) this._markAccepted(text) // 수락한 문장은 사용자 문체의 확실한 표본이다 (학습 동의 시에만 저장됨). getInputTelemetryService().recordExternalText(text, { appName, windowTitle, source: 'suggestion' }) @@ -1331,7 +1341,8 @@ class SuggestionService extends EventEmitter { maxCandidates: SUGGESTION_DEFAULTS.maxCandidates, maxChars: SUGGESTION_MAX_OUTPUT_CHARS, // 터미널은 셸 프롬프트라 문장 제안이 의미 없다 — 사용자 목록과 무관하게 뺀다. - excludedApps: [...configGet('inputExcludedApps'), ...TERMINAL_APPS] + // 터미널도 제안한다(입력 줄만 추출). 학습 제외는 InputTelemetryService 가 따로 한다. + excludedApps: [...configGet('inputExcludedApps')] } } diff --git a/packages/core/__tests__/terminal-prompt.test.ts b/packages/core/__tests__/terminal-prompt.test.ts new file mode 100644 index 0000000..5ce3752 --- /dev/null +++ b/packages/core/__tests__/terminal-prompt.test.ts @@ -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' }) + }) +}) diff --git a/packages/core/src/input-intelligence.ts b/packages/core/src/input-intelligence.ts index a7e797e..2f625b8 100644 --- a/packages/core/src/input-intelligence.ts +++ b/packages/core/src/input-intelligence.ts @@ -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 +} + /** * 학습에서 빼는 앱 — 터미널 + 코드 에디터 + 코딩 에이전트 허브. *