From 7e0c20b510a49dd0b19cc645e191afd87fe56e5c Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Sun, 27 Sep 2026 22:43:22 +0900 Subject: [PATCH] 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. --- .../src/main/services/SuggestionService.ts | 19 ++++- .../core/__tests__/terminal-prompt.test.ts | 76 +++++++++++++++++++ packages/core/src/input-intelligence.ts | 53 ++++++++++++- 3 files changed, 143 insertions(+), 5 deletions(-) create mode 100644 packages/core/__tests__/terminal-prompt.test.ts 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 +} + /** * 학습에서 빼는 앱 — 터미널 + 코드 에디터 + 코딩 에이전트 허브. *