diff --git a/apps/mcp-server/README.md b/apps/mcp-server/README.md index 1fa549f..5e877b2 100644 --- a/apps/mcp-server/README.md +++ b/apps/mcp-server/README.md @@ -77,7 +77,7 @@ Claude Code hooks ──stdin──▶ ~/.claude/haribo/hook.mjs ──POST /hoo | Claude Code 이벤트 | 서버 주소 | 띄우는 것 | | -------------------------------------- | --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -| `SessionStart` (startup) | `POST /hooks/session-start` | 켤 때 알림 `● [app] 결정 사항이 1건 변경되었어요.` 답할 변경이 없으면 아무것도 | +| `SessionStart` (startup) | `POST /hooks/session-start` | 켤 때 알림 `[app] 결정 사항이 1건 변경되었어요.`(앞에 노란 막대) 답할 변경이 없으면 아무것도 | | `UserPromptSubmit` | `POST /hooks/prompt-submit` | 첫 변경 상세 + Claude에게 질문 순서 안내. 같은 세션에서 닫고 넘어갔으면 한 줄 알림만 (SIL-80 B5). `결정 사항 확인`을 입력하면 다시 물어요 | | `PostToolUse` (`mcp__haribo__respond`) | `POST /hooks/after-respond` | `✓ 기록했어요` + 다음 변경 상세, 또는 마무리 한 줄 | diff --git a/apps/mcp-server/src/hooks/notify-hooks.test.ts b/apps/mcp-server/src/hooks/notify-hooks.test.ts index 0ab7129..12d92ce 100644 --- a/apps/mcp-server/src/hooks/notify-hooks.test.ts +++ b/apps/mcp-server/src/hooks/notify-hooks.test.ts @@ -147,7 +147,7 @@ describe('① 켤 때 (session-start)', () => { it('답할 변경이 있으면 켤 때 알림을 돌려줘요', async () => { const result = await hook('session-start', { session_id: 's1' }); expect(result.status).toBe(200); - expect(result.screen).toContain('● [app] 결정 사항이 1건 변경되었어요.'); + expect(result.screen).toContain(' [app] 결정 사항이 1건 변경되었어요.'); }); it('바꾼 부건님에게는 아무것도 띄우지 않아요', async () => { @@ -169,7 +169,7 @@ describe('② 작업을 입력할 때 (prompt-submit)', () => { session_id: 's1', prompt: '로그인 화면 문구 고쳐 줘', }); - expect(result.screen).toContain('작업 전에 확인할 변경이 1건 있어요.'); + expect(result.screen).toContain('작업 전에 확인할 변경이 1건 있어요 '); expect(result.screen).toContain(' 변경 40자 ⭢ 32자'); expect(result.context).toContain('mcp__haribo__list_pending'); expect(result.context).toContain('mcp__haribo__respond'); @@ -184,7 +184,7 @@ describe('② 작업을 입력할 때 (prompt-submit)', () => { prompt: '작업 2', }); expect(second.screen).toContain( - '● 확인할 변경 1건 · "결정 사항 확인"을 입력하면 변경 내역을 볼 수 있어요.', + ' 확인할 변경 1건 · "결정 사항 확인"을 입력하면 변경 내역을 볼 수 있어요.', ); expect(second.context).toContain('사용자의 요청을 그대로 처리한다'); }); @@ -195,7 +195,7 @@ describe('② 작업을 입력할 때 (prompt-submit)', () => { session_id: 's1', prompt: '결정 사항 확인', }); - expect(recall.screen).toContain('확인할 변경이 1건 있어요.'); + expect(recall.screen).toContain('확인할 변경이 1건 있어요 '); expect(recall.screen).not.toContain('작업 전에'); expect(recall.context).toContain('따로 처리할 요청이 없다'); }); @@ -206,7 +206,7 @@ describe('② 작업을 입력할 때 (prompt-submit)', () => { session_id: 's2', prompt: '작업 1', }); - expect(other.screen).toContain('작업 전에 확인할 변경이 1건 있어요.'); + expect(other.screen).toContain('작업 전에 확인할 변경이 1건 있어요 '); }); it('답할 변경이 없으면 아무것도 띄우지 않고, "결정 사항 확인"이면 없다고 알려요', async () => { @@ -296,7 +296,7 @@ describe('답한 뒤 (after-respond)', () => { session_id: 's1', prompt: '작업', }); - expect(first.screen).toContain('작업 전에 확인할 변경이 2건 있어요.'); + expect(first.screen).toContain('작업 전에 확인할 변경이 2건 있어요 '); // 최신 것(c-9)이 먼저 와요 await respond(store, { pendingCheckId: 'c-9', @@ -312,7 +312,7 @@ describe('답한 뒤 (after-respond)', () => { expect(after.screen).toContain( '✓ 기록했어요 #5 · 관계없다 · 내 작업 범위가 아니에요', ); - expect(after.screen).toContain('확인할 변경이 1건 남았어요.'); + expect(after.screen).toContain('확인할 변경이 1건 남았어요 '); expect(after.screen).toContain(' 2/2 '); }); diff --git a/packages/notify/README.md b/packages/notify/README.md index 8446560..2ab5157 100644 --- a/packages/notify/README.md +++ b/packages/notify/README.md @@ -96,15 +96,16 @@ checkReflect(check, answer, { list_pending 결과로 터미널 알림 글을 만들어요. 모양과 문구는 부건님 시안(SIL-68, `docs/sil-68-add-cli-design-notes` 브랜치의 `cli-design-notes/`)을 그대로 옮겼어요. 팀 MCP 서버의 `/hooks/<이벤트>`가 이 함수로 글을 만들고, 팀원 PC의 hooks가 그대로 띄워요 (`apps/mcp-server` README "터미널 알림 hooks"). -| 함수 | 화면 | -| ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ | -| `sessionNotice(items)` | ① 켤 때: `● [app] 결정 사항이 1건 변경되었어요.` 답할 변경이 없으면 null | -| `detailScreen(item, index, total)` | ② 작업 입력 때: 변경 상세 (제목, 바꾼 사람·시각, 변경, 이유, 이슈, 출처) | -| `questionsFor(item)` | 선택 화면 질문 3개 (관계, 알았나, 관계없는 이유 보기 3개) | -| `recordedLines(item, answer)` | 답한 뒤: `✓ 기록했어요 #5 · 관계있다 · 몰랐다` | -| `allAnsweredLine`, `skippedReminder`, `nothingLeft`, `autoClosedLine`, `connectionFailedNotice` | 마무리, 닫고 넘어간 뒤, 0건, 자동 닫힘, 연결 실패 한 줄 | +| 함수 | 화면 | +| ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | +| `sessionNotice(items)` | ① 켤 때: 노란 막대 + `[app] 결정 사항이 1건 변경되었어요.` 답할 변경이 없으면 null | +| `detailScreen(item, index, total)` | ② 작업 입력 때: 위아래 선으로 묶은 변경 상세 (머리표, 제목·종류 태그, 바꾼 사람·시각, 변경·상세, 이유, 이슈, 출처) | +| `questionsFor(item)` | 선택 화면 질문 3개 (관계, 알았나, 관계없는 이유 보기 3개) | +| `recordedLines(item, answer)` | 답한 뒤: `✓ 기록했어요 #5 · 관계있다 · 몰랐다` | +| `allAnsweredLine`, `skippedReminder`, `nothingLeft`, `autoClosedLine`, `connectionFailedNotice` | 마무리, 닫고 넘어간 뒤, 0건, 자동 닫힘, 연결 실패 한 줄 | - 너비는 76칸이에요. Claude Code가 hooks 글 앞에 2칸을 들여 써요. +- 모양은 v2 시안(10/13)이에요: 상태 줄 앞 노란 막대, 상세 위아래 선과 머리표(`-- [1/3] 작업 전에 확인할 변경이 3건 있어요 ---`), 청록 굵은 제목, 종류 태그 `(변경)` `(신규)` `(되돌림)`, 지금 값 전체를 두 줄까지 보여 주는 `상세` 줄, 초록 `✓ 기록했어요`. - 이전 버전에 "관계있다"로 답했으면 변경 줄 아래에 "지난번 변경(32자)을 반영하셨다면 다시 고쳐야 해요."를 넣어요 (SIL-77). - 바꾼 시각은 한국 시각으로, 오늘이면 "오후 2:13", 아니면 "10월 13일 오후 2:13"이에요. diff --git a/packages/notify/src/terminal/screens.test.ts b/packages/notify/src/terminal/screens.test.ts index 4a290cb..76a4505 100644 --- a/packages/notify/src/terminal/screens.test.ts +++ b/packages/notify/src/terminal/screens.test.ts @@ -64,7 +64,7 @@ describe('켤 때 알림 (T2)', () => { it('1건이면 "총" 없이 영역과 건수만 보여 준다', () => { assert.deepEqual(plain(sessionNotice([demoItem()]) ?? ''), [ '', - '● [app] 결정 사항이 1건 변경되었어요.', + ' [app] 결정 사항이 1건 변경되었어요.', ' 작업을 시작하면 변경 내역을 상세히 볼 수 있어요.', ]); }); @@ -76,7 +76,7 @@ describe('켤 때 알림 (T2)', () => { }); assert.equal( plain(sessionNotice([demoItem(), notifyItem]) ?? '')[1], - '● [notify, app] 결정 사항이 총 2건 변경되었어요.', + ' [notify, app] 결정 사항이 총 2건 변경되었어요.', ); }); @@ -89,35 +89,58 @@ describe('켤 때 알림 (T2)', () => { const yellowBoldOne = '\u001b[1m\u001b[33m1'; assert.ok((sessionNotice([demoItem()]) ?? '').includes(yellowBoldOne)); }); + + it('첫 줄 앞에는 ● 대신 노란 막대를 세운다 (v2)', () => { + const yellowBar = '\u001b[33m\u001b[7m \u001b[27m\u001b[39m '; + const lines = (sessionNotice([demoItem()]) ?? '').split('\n'); + assert.ok(lines[1]?.startsWith(yellowBar)); + assert.ok(!lines[2]?.includes(yellowBar), '설명 줄에는 막대가 없어요'); + }); }); describe('변경 상세 (T3)', () => { - it('데모 예시가 부건님 시안 모양 그대로 나온다', () => { + it('데모 예시가 부건님 v2 시안 모양 그대로 나온다', () => { assert.deepEqual(plain(detailScreen(demoItem(), 1, 1, { now: SAME_DAY })), [ '', - '작업 전에 확인할 변경이 1건 있어요.', - '', - '[app] 앱 알림 제목은 32자 이하로 쓴다', + `-- 작업 전에 확인할 변경이 1건 있어요 ${'-'.repeat(38)}`, + '[app] 앱 알림 제목은 32자 이하로 쓴다 (변경)', ' 부건님이 오후 2:13에 변경했어요.', ' 변경 40자 ⭢ 32자', + ' 상세 공백 포함 32자 이하', '', ' 이유 Windows 알림 창에서 32자를 넘으면 제목 끝이 잘림', ' 이슈 #5 담당자에게 알리고 답 기록하기', ' 출처 AI 대화', + '-'.repeat(COLS), ]); }); - it('2건 이상이면 반전 칩을 붙이고, 두 번째부터는 남은 건수를 보여 준다', () => { + it('위아래 선은 76칸이다', () => { + const lines = plain(detailScreen(demoItem(), 2, 3, { now: SAME_DAY })); + assert.equal(textWidth(lines[1] ?? ''), COLS); + assert.equal(textWidth(lines.at(-1) ?? ''), COLS); + }); + + it('제목은 청록 굵은 글자다 (v2)', () => { + const cyanBold = '\u001b[1m\u001b[36m앱 알림 제목은 32자 이하로 쓴다'; + assert.ok( + detailScreen(demoItem(), 1, 1, { now: SAME_DAY }).includes(cyanBold), + ); + }); + + it('2건 이상이면 위 선 머리표에 반전 칩을 붙이고, 두 번째부터는 남은 건수를 보여 준다', () => { const lines = plain(detailScreen(demoItem(), 2, 3, { now: SAME_DAY })); - assert.equal(lines[1], '확인할 변경이 2건 남았어요.'); - assert.equal(lines[3], ' 2/3 '); + assert.match( + lines[1] ?? '', + /^-- {2}2\/3 {2}확인할 변경이 2건 남았어요 -+$/, + ); }); it('"결정 사항 확인"으로 다시 부르면 "작업 전에"를 뺀다', () => { const lines = plain( detailScreen(demoItem(), 1, 1, { now: SAME_DAY, recall: true }), ); - assert.equal(lines[1], '확인할 변경이 1건 있어요.'); + assert.match(lines[1] ?? '', /^-- 확인할 변경이 1건 있어요 -+$/); }); it('오늘이 아니면 날짜를 함께 쓴다', () => { @@ -127,7 +150,7 @@ describe('변경 상세 (T3)', () => { ); }); - it('새로 생긴 결정 사항은 (신규) 표시와 "새로 생긴 결정 사항이에요"', () => { + it('새로 생긴 결정 사항은 (신규) 태그와 상세만 보여 준다', () => { const item = demoItem(); item.standard = { ...item.standard, @@ -135,20 +158,25 @@ describe('변경 상세 (T3)', () => { previousVersion: null, }; const lines = plain(detailScreen(item, 1, 1, { now: SAME_DAY })); - assert.equal(lines[3], '[app] 앱 알림 제목은 32자 이하로 쓴다 (신규)'); - assert.equal(lines[5], ' 변경 새로 생긴 결정 사항이에요'); + assert.equal(lines[2], '[app] 앱 알림 제목은 32자 이하로 쓴다 (신규)'); + assert.equal(lines[4], ' 상세 공백 포함 32자 이하'); + assert.equal(lines[5], ''); }); it('되돌림은 (되돌림)을 회색 글자색으로 붙인다 (notify.revert.mark-by-screen, 임시 회색)', () => { const item = demoItem(); item.standard = { ...item.standard, isRevert: true }; const text = detailScreen(item, 1, 1, { now: SAME_DAY }); - assert.match(plain(text)[3] ?? '', /\(되돌림\)$/); + assert.match(plain(text)[2] ?? '', /\(되돌림\)$/); const grayRevert = '\u001b[90m (되돌림)\u001b[39m'; assert.ok(text.includes(grayRevert), '회색(90) 글자색이 아니에요'); }); - it('(신규)는 지금처럼 흐린 글자다', () => { + it('(변경)과 (신규)는 흐린 글자다', () => { + const dimChange = '\u001b[2m (변경)\u001b[22m'; + assert.ok( + detailScreen(demoItem(), 1, 1, { now: SAME_DAY }).includes(dimChange), + ); const item = demoItem(); item.standard = { ...item.standard, @@ -169,12 +197,12 @@ describe('변경 상세 (T3)', () => { '변경마다 기준 제목, 이전 값, 지금 값, 이유, 바꾼 사람을 보여 준다', }; assert.equal( - plain(detailScreen(removed, 1, 1, { now: SAME_DAY }))[5], + plain(detailScreen(removed, 1, 1, { now: SAME_DAY }))[4], ' 변경 "근거," 삭제', ); }); - it('크게 바뀌면 "문장을 새로 썼어요"와 이전 값을 보여 준다', () => { + it('크게 바뀌면 이전 값 한 줄과 상세(지금 값)를 보여 준다', () => { const item = demoItem(); item.standard = { ...item.standard, @@ -182,8 +210,26 @@ describe('변경 상세 (T3)', () => { value: '앱은 웹 앱으로 만들고 PWA로 설치할 수 있게 한다', }; const lines = plain(detailScreen(item, 1, 1, { now: SAME_DAY })); - assert.equal(lines[5], ' 변경 문장을 새로 썼어요'); - assert.equal(lines[6], ' 이전 데스크톱 앱은 Electron으로 만든다'); + assert.equal(lines[4], ' 이전 데스크톱 앱은 Electron으로 만든다'); + assert.equal( + lines[5], + ' 상세 앱은 웹 앱으로 만들고 PWA로 설치할 수 있게 한다', + ); + }); + + it('긴 상세는 두 줄까지 접고, 넘치면 끝을 줄인다', () => { + const item = demoItem(); + item.standard = { + ...item.standard, + previousValue: '짧은 값', + value: Array.from({ length: 40 }, (_, i) => `낱말${i}`).join(' '), + }; + const text = detailScreen(item, 1, 1, { now: SAME_DAY }); + assertFits(text); + const lines = plain(text); + assert.match(lines[5] ?? '', /^ {2}상세 {2}낱말0 /); + assert.match(lines[6] ?? '', /^ {8}낱말\d+ .*…$/); + assert.equal(lines[7], ''); }); it('이전 버전에 관계있다고 답했으면 빈 줄 자리에 "다시 고쳐야 해요"를 넣는다 (SIL-77)', () => { @@ -201,7 +247,7 @@ describe('변경 상세 (T3)', () => { value: '공백 포함 30자 이하', }; const lines = plain(detailScreen(item, 1, 1, { now: SAME_DAY })); - assert.equal(lines[5], ' 변경 32자 ⭢ 30자'); + assert.equal(lines[4], ' 변경 32자 ⭢ 30자'); assert.equal( lines[6], ' 지난번 변경(32자)을 반영하셨다면 다시 고쳐야 해요.', @@ -232,7 +278,7 @@ describe('변경 상세 (T3)', () => { }; const text = detailScreen(item, 1, 2, { now: SAME_DAY }); assertFits(text); - assert.match(plain(text)[4] ?? '', /…$/); + assert.match(plain(text)[2] ?? '', /… \(변경\)$/); }); it('Claude Code 머리줄을 더해도 15줄 안에 들어간다 (착수 안내 T3)', () => { @@ -248,6 +294,15 @@ describe('변경 상세 (T3)', () => { }); describe('답한 뒤 (T4·T5)', () => { + it('"✓ 기록했어요"는 통째로 초록이다 (v2)', () => { + const [first] = recordedLines(demoItem(), { + affected: true, + known: false, + unaffectedReason: null, + }); + assert.ok(first?.startsWith('\u001b[32m✓ 기록했어요\u001b[39m')); + }); + it('관계있다면 기록 줄과 "다음 작업부터 따라요"', () => { assert.deepEqual( recordedLines(demoItem(), { @@ -287,7 +342,7 @@ describe('답한 뒤 (T4·T5)', () => { it('닫고 넘어간 뒤 알림, 0건, 자동 닫힘 한 줄', () => { assert.equal( plain(skippedReminder(2))[1], - '● 확인할 변경 2건 · "결정 사항 확인"을 입력하면 변경 내역을 볼 수 있어요.', + ' 확인할 변경 2건 · "결정 사항 확인"을 입력하면 변경 내역을 볼 수 있어요.', ); assert.equal(plain(nothingLeft())[1], '✓ 확인할 변경이 없어요.'); assert.equal( @@ -299,7 +354,7 @@ describe('답한 뒤 (T4·T5)', () => { it('연결에 실패하면 실패와 다시 시작하는 방법을 알린다 (SIL-80 B4)', () => { assert.deepEqual(plain(connectionFailedNotice()), [ '', - '● 서버에 연결하지 못해서 확인할 변경을 불러오지 못했어요.', + ' 서버에 연결하지 못해서 확인할 변경을 불러오지 못했어요.', ' "결정 사항 확인"을 입력하면 다시 시작해요.', ]); }); diff --git a/packages/notify/src/terminal/screens.ts b/packages/notify/src/terminal/screens.ts index 2c22a52..2daae58 100644 --- a/packages/notify/src/terminal/screens.ts +++ b/packages/notify/src/terminal/screens.ts @@ -3,6 +3,8 @@ * * 모양과 문구는 부건님 결정 기록(cli-design-notes/터미널-알림-흐름-결정기록.md, T3-질문과-답-결정기록.md)과 * 움직이는 시안(prototype/notify-prototype/proto/notice.py)을 그대로 옮겼어요. 비교 방식은 결정된 "요약(E)"만 써요. + * 화면은 v2 시안(10/13 부건, prototype/업데이트-내역.md "v2 화면")을 따라요: 노란 막대, 위아래 선과 머리표, + * 청록 제목, 종류 태그 네 가지, 상세 줄, 초록 "✓ 기록했어요" (SIL-67). * 데이터는 list_pending 결과(PendingItem)를 받아요. hooks가 이 글을 화면에 직접 띄워요. * * 꾸밈 규칙 (결정 기록 4장, 6장): @@ -44,10 +46,33 @@ const dim = (text: string) => `${esc('2')}${text}${esc('22')}`; const strongLine = (text: string) => `${esc(STRONG)}${text}${esc('39')}`; const green = (text: string) => `${esc('32')}${text}${esc('39')}`; const check = green('✓'); +const YELLOW = '33'; + +/** + * 상태 줄(켤 때 첫 줄, 닫은 뒤 한 줄, 연결 실패) 앞에 세우는 노란 막대: 노랑을 반전한 칸 하나 + 빈칸 (v2). + * v1의 ●는 Claude가 답할 때 붙이는 점과 같아서 Claude의 말처럼 읽혔어요 (v1 피드백 P1·P2). + * 막대와 빈칸이 2칸이라 아래 설명 줄(2칸 들여쓰기)과 글자 시작이 맞아요. + */ +const BAR = `${esc(YELLOW)}${esc('7')} ${esc('27')}${esc('39')} `; +const statusLine = (text: string) => `${BAR}${strongLine(text)}`; + +/** 입력할 말: 노란 굵은 글자, 따옴표는 터미널 기본 글자색 (v2) */ +const recallWord = () => + `"${esc('1')}${esc(YELLOW)}${RECALL_PHRASE}${esc(STRONG)}${esc('22')}"`; + +/** 상세 제목: 청록 굵은 글자 (v2). 끝나면 줄 색으로 돌아가 뒤의 태그가 회색이 되지 않게 해요 */ +const TITLE_COLOR = '36'; +const titleText = (title: string) => + `${esc('1')}${esc(TITLE_COLOR)}${title}${esc(STRONG)}${esc('22')}`; + +/** 상세를 묶는 선. 어느 설정에서나 1칸인 영문 하이픈이에요 (상자 선 ─ 는 한국어 글꼴에서 2칸) */ +const RULE = '-'; +/** 상세 줄은 두 줄까지 접고, 넘치면 끝을 … 으로 (값은 최대 120자) */ +const DETAIL_LINES = 2; /** 건수 숫자: 노란 굵은 글자. 숫자 뒤에는 기본 글자색으로 돌아가요 */ const countText = (count: number) => - `${esc('1')}${esc('33')}${count}${esc(STRONG)}${esc('22')}`; + `${esc('1')}${esc(YELLOW)}${count}${esc(STRONG)}${esc('22')}`; /** 몇 번째 변경인지: 반전 칩 */ const countChip = (index: number, total: number) => @@ -83,10 +108,11 @@ export function sessionNotice(items: PendingItem[]): string | null { const pending = items.filter((item) => item.status === 'pending'); if (pending.length === 0) return null; const total = pending.length > 1 ? '총 ' : ''; + // 첫 줄(상태 줄)에만 노란 막대. 아래 설명 줄에는 넣지 않아요 (v2) return [ '', - strongLine( - `● ${areaLabel(pending)} 결정 사항이 ${total}${countText(pending.length)}건 변경되었어요.`, + statusLine( + `${areaLabel(pending)} 결정 사항이 ${total}${countText(pending.length)}건 변경되었어요.`, ), ' 작업을 시작하면 변경 내역을 상세히 볼 수 있어요.', ].join('\n'); @@ -111,8 +137,9 @@ function kindOf(item: PendingItem): ChangeKind { return 'normal'; } -const TAGS: Record = { - normal: null, +/** 제목 끝 종류 태그. v2에서 일반 변경에도 (변경)을 붙여요. 폐기는 아직 알리지 않아서 없어요 */ +const TAGS: Record = { + normal: '변경', new: '신규', revert: '되돌림', }; @@ -121,11 +148,10 @@ const TAGS: Record = { * 제목 끝의 종류 표시. * - (되돌림)은 괄호 글자에 글자색만 입혀요 (notify.revert.mark-by-screen). 색은 SIL-81에서 정하는 중이라 * 부건님과 정한 대로 임시로 회색(테마 16색의 밝은 검정 90)을 써요 (10/14). - * - (신규)는 정해진 색이 없어서 시안대로 흐린 글자예요. + * - (변경)·(신규)는 시안대로 흐린 글자예요. */ function tagText(kind: ChangeKind): string { const tag = TAGS[kind]; - if (!tag) return ''; if (kind === 'revert') return `${esc('90')} (${tag})${esc('39')}`; return dim(` (${tag})`); } @@ -176,25 +202,64 @@ export function sourceText(item: PendingItem): string { const label = (name: string) => ` ${name} `; -/** 변경 줄 (결정 기록 3-5). 그 아래 한 줄은 빈 줄이거나 "다시 고쳐야 해요" 안내예요 */ +/** + * 값 전체를 낱말 단위로 76칸 안에 접어요. 둘째 줄은 이름표 폭(8칸)만큼 들여 써요. + * 두 줄을 넘으면 둘째 줄 끝을 … 으로 줄여요. + */ +function detailValueLines(text: string): string[] { + const budget = COLS - 8; + const rows: string[] = []; + let current = ''; + for (const word of text.split(' ')) { + const next = current ? `${current} ${word}` : word; + if (current && textWidth(next) > budget) { + rows.push(current); + current = word; + } else { + current = next; + } + } + rows.push(current); + const shown = + rows.length > DETAIL_LINES + ? [ + ...rows.slice(0, DETAIL_LINES - 1), + cutEnd(`${rows[DETAIL_LINES - 1]} …`, budget), + ] + : rows; + return shown.map( + (row, i) => + `${i === 0 ? label('상세') : ' '.repeat(8)}${strongLine(cutEnd(row, budget))}`, + ); +} + +/** + * 변경 줄과 상세 줄 (v2 "요약"). 종류는 제목 끝 태그가 말하고, 상세는 지금 값 전체예요. + * - 변경·되돌림: 바뀐 낱말 요약(변경 줄) 또는 크게 바뀌면 이전 값 한 줄 → 상세 + * - 신규: 상세만 + * 지금 값이 상세로 들어가서 위키 notify.terminal.show-fields(이전 값·지금 값)와 맞아요. + * 그 아래 한 줄은 빈 줄이거나 "다시 고쳐야 해요" 안내예요. + */ function changeLines(item: PendingItem): string[] { const previous = item.standard.previousValue; const current = item.standard.value; - if (previous === null) return [`${label('변경')}새로 생긴 결정 사항이에요`]; + const valueLines = detailValueLines(current); + if (previous === null) return valueLines; if (isLargeChange(previous, current)) { - return [ - `${label('변경')}문장을 새로 썼어요`, - `${label('이전')}${cutEnd(previous, COLS - 8)}`, - ]; + return [`${label('이전')}${cutEnd(previous, COLS - 8)}`, ...valueLines]; } + return [`${label('변경')}${changeWhat(previous, current)}`, ...valueLines]; +} + +/** 바뀐 낱말만. 일대일로 바뀌면 이전은 흐리게, 지금은 굵게 (10/12 부건) */ +function changeWhat(previous: string, current: string): string { const [oldWords, newWords] = changedWords(previous, current); if (oldWords && newWords) { - // 일대일로 바뀌면 이전 텍스트는 흐리게, 바뀐 부분은 굵게 (10/12 부건) - return [`${label('변경')}${dim(oldWords)} ${ARROW} ${bold(newWords)}`]; + return `${dim(oldWords)} ${ARROW} ${bold(newWords)}`; } - if (oldWords) return [`${label('변경')}"${bold(oldWords)}" 삭제`]; - if (newWords) return [`${label('변경')}"${bold(newWords)}" 추가`]; - return [`${label('변경')}값은 그대로예요`]; + if (oldWords) return `"${bold(oldWords)}" 삭제`; + if (newWords) return `"${bold(newWords)}" 추가`; + return '값은 그대로예요'; } /** @@ -222,17 +287,32 @@ function issueText(item: PendingItem): string { return done ? `${text} ${green('[완료]')}` : text; } -/** 맨 위 안내: 처음에는 전체 건수, 다음부터는 남은 건수(지금 보는 것 포함) */ +/** 머리표 문장: 처음에는 전체 건수, 다음부터는 남은 건수(지금 보는 것 포함) */ function progress(index: number, total: number, recall: boolean): string { if (index === 1) { - return bold( - `${recall ? '' : '작업 전에 '}확인할 변경이 ${total}건 있어요.`, - ); + return `${recall ? '' : '작업 전에 '}확인할 변경이 ${total}건 있어요`; } - return bold(`확인할 변경이 ${total - index + 1}건 남았어요.`); + return `확인할 변경이 ${total - index + 1}건 남았어요`; +} + +/** + * 위 선: "-- [1/3] 작업 전에 확인할 변경이 3건 있어요 -------" (1건이면 칩 없이, v2). + * 칩은 머리표 문장 왼쪽에 두고, 문장은 Claude Code 기본 회색·보통 굵기예요. 선과 칩은 기본 글자색이에요. + */ +function ruleTop(index: number, total: number, recall: boolean): string { + const lead = `${RULE.repeat(2)} `; + const chip = total > 1 ? `${countChip(index, total)} ` : ''; + const chipWidth = total > 1 ? textWidth(` ${index}/${total} `) : 0; + const head = `${progress(index, total, recall)} `; + const fill = RULE.repeat( + Math.max(2, COLS - textWidth(lead + head) - chipWidth), + ); + return `${strongLine(lead)}${chip}${head}${strongLine(fill)}`; } -/** 변경 상세 화면 하나 (진행 표시 → 제목 → 바꾼 사람·시간 → 변경 → 이유 · 이슈 · 출처) */ +const ruleBottom = () => strongLine(RULE.repeat(COLS)); + +/** 변경 상세 화면 하나 (위 선 → 제목·태그 → 바꾼 사람·시간 → 변경·상세 → 이유 · 이슈 · 출처 → 아래 선) */ export function detailScreen( item: PendingItem, index: number, @@ -241,23 +321,22 @@ export function detailScreen( ): string { const now = options.now ?? new Date().toISOString(); const area = areaOf(item); - const tag = TAGS[kindOf(item)]; - const tagWidth = tag ? textWidth(tag) + 3 : 0; + const kind = kindOf(item); + const tagWidth = textWidth(TAGS[kind]) + 3; const headWidth = textWidth(area) + 3; // [영역] + 빈칸 const title = cutEnd(item.standard.title, COLS - headWidth - tagWidth); const who = item.standard.changedBy ?? '누군가'; return [ '', - progress(index, total, options.recall ?? false), - '', - ...(total > 1 ? [countChip(index, total)] : []), - strongLine(`[${area}] ${bold(title)}${tagText(kindOf(item))}`), + ruleTop(index, total, options.recall ?? false), + strongLine(`[${area}] ${titleText(title)}${tagText(kind)}`), `${' '.repeat(headWidth)}${who}님이 ${formatWhen(item.standard.changedAt, now)}에 변경했어요.`, ...changeLines(item), reworkLine(item), `${label('이유')}${cutEnd(item.standard.reason, COLS - 8)}`, `${label('이슈')}${issueText(item)}`, `${label('출처')}${sourceText(item)}`, + ruleBottom(), ].join('\n'); } @@ -272,7 +351,7 @@ export interface RecordedAnswer { unaffectedReason: string | null; } -/** "✓ 기록했어요 #5 · 관계있다 · 몰랐다" (+ 관계있다면 다음 작업부터 따른다는 둘째 줄) */ +/** "✓ 기록했어요 #5 · 관계있다 · 몰랐다" (+ 관계있다면 다음 작업부터 따른다는 둘째 줄). "✓ 기록했어요"는 초록 (v2) */ export function recordedLines( item: PendingItem, answer: RecordedAnswer, @@ -284,7 +363,7 @@ export function recordedLines( : '몰랐다' : (answer.unaffectedReason ?? ''); const lines = [ - `${check} 기록했어요 ${strongLine(cutEnd([item.issue.key, relation, second].join(' · '), COLS - 16))}`, + `${green('✓ 기록했어요')} ${strongLine(cutEnd([item.issue.key, relation, second].join(' · '), COLS - 16))}`, ]; if (answer.affected) { // 위키 notify.answer.next-run-context "다음 실행부터"에 맞춘 문구 (부건님 T3 결정 기록) @@ -300,12 +379,15 @@ export function allAnsweredLine(totalAtStart: number, recall: boolean): string { return bold(`확인할 변경${count}을 모두 답했어요.${start}`); } -/** 질문을 닫고 넘어간 뒤 작업을 입력할 때마다 띄우는 한 줄 */ +/** + * 질문을 닫고 넘어간 뒤 작업을 입력할 때마다 띄우는 한 줄 (notify.terminal.new-after-answered). + * 모양은 v2 상태 줄(노란 막대)이에요. v2 시안은 작업을 마친 뒤(Stop hooks)에 띄우지만, 띄우는 때는 위키대로 입력할 때예요. + */ export function skippedReminder(count: number): string { return [ '', - strongLine( - `● 확인할 변경 ${countText(count)}건 · ${bold(`"${RECALL_PHRASE}"`)}을 입력하면 변경 내역을 볼 수 있어요.`, + statusLine( + `확인할 변경 ${countText(count)}건 · ${recallWord()}을 입력하면 변경 내역을 볼 수 있어요.`, ), ].join('\n'); } @@ -317,8 +399,8 @@ export function skippedReminder(count: number): string { export function connectionFailedNotice(): string { return [ '', - strongLine('● 서버에 연결하지 못해서 확인할 변경을 불러오지 못했어요.'), - ` ${bold(`"${RECALL_PHRASE}"`)}을 입력하면 다시 시작해요.`, + statusLine('서버에 연결하지 못해서 확인할 변경을 불러오지 못했어요.'), + ` ${recallWord()}을 입력하면 다시 시작해요.`, ].join('\n'); }