git:20261007.4433684 to git:20261009.4760c4e

12 added, 7 removed. Audit A to A.

---
name: tui-chat-design
description: "masc TUI 채팅 화면이 무엇을 어떤 기호로 그리는지 정리한 표와 규칙이다. 말한 사람 마크, 턴 레일, 왼쪽 여백의 순서, 시계 칸, Ctrl-F 밀도 단계, 밝기 위계와 끝난 일의 접힘, 줄 문구를 바꾸거나 새 기호를 넣기 전에 쓴다. 바꾼 화면을 실제로 띄워 확인하는 방법은 tui-pty-scenario 에 있다."
---
# masc 채팅 화면의 기호와 규칙
이 화면에는 이미 정해진 기호 체계가 있다. 새 기호를 만들기 전에 있는 기호로 말할 수 있는지 먼저 본다.
아래 값의 출처는 `bin/masc_tui_message_layout.ml` 과 `bin/masc_tui_render_chat.ml` 이다.
이 문서와 코드가 다르면 코드가 맞다. 그 자리에서 이 문서를 고친다.
## 말한 사람 — `speaker_mark`
| 기호 | 스타일 | 뜻 |
|---|---|---|
| `▶` | `User` | 운영자가 보냈다 |
| `◀` | `Inbound` | 바깥의 누군가가 여기로 보냈다 |
| `●` | `Keeper` | 키퍼가 말한다 |
| `○` | `Status` | 그 턴에 대한 화면의 메모. 키퍼가 한 말이 아니다 |
| `›` | `Local` | 입력줄 프롬프트와 같은 기호. 방금 친 것에 화면이 답한다 |
| `◈` | `Journal` | 나란히 도는 Memory 저널 레인 |
| `■` | `Tool` | 도구 |
| `◇` `◆` `△` `✗` | `Skill _` | 스킬: 도는 중 / 썼음 / 볼 것 / 실패 |
| `·` | `Thinking` | 생각 중 |
| `✗` | `Error` | 오류 |
같은 `✗` 는 Overview 의 TUI Session Events 패널에서도 오류를 뜻한다. `event_type = "error"` 인 행만
시계 뒤에 `✗ ` 를 달고, 나머지 레벨은 표식 없이 그린다(`Masc_tui_types.overview_event_mark`).
### 이 표를 만든 규칙 둘
- **기호는 도형이다.** `?` 같은 문장 부호는 쓰지 않는다. 끝난 줄에 물음표가 남으면 아무도 답하지
않은 것처럼 읽힌다. 화면의 메모가 `○` 인 이유다.
- **채운 것과 빈 것이 짝이다.** `●`/`○`, `◆`/`◇` 에서 채운 쪽은 그 일 자체이고, 빈 쪽은 그 일에
대한 화면의 메모다. 새 상태가 필요하면 이 짝으로 말할 수 있는지 먼저 본다.
### 색은 구분의 근거가 못 된다
`NO_COLOR` 가 켜지면 색이 사라진다. 그래서 누가 말했는지는 색이 아니라 마크가 답해야 한다.
색으로만 구분되는 상태를 만들면 색 없는 터미널에서는 그 상태가 안 보인다.
## 밝기는 위계다
모양이 무엇인가를, 색이 누구인가·어떤 상태인가를 말한다면, 밝기는 위계만 말한다.
밝은 자리는 화면에 몇 개 없어야 한다.
- **말만 전경이다.** `User`·`Inbound`·`Keeper` 본문은 터미널 전경을 유지한다. `Local` 은
명령에 대한 답이라 읽는 글이라 누르지 않는다.
- **일·소식·생각은 한 단계 아래다.** `Tool`·`Skill`·`Journal`·`Thinking` 본문은 dim 이다
(`Chat_theme.body`, `bin/masc_tui_ansi.ml`). dim 본문 안의 bold·링크가 닫힐 때 dim 을 다시
연다 — 스팬 하나가 그 뒤의 줄을 전부 밝히지 못한다.
- **크롬은 가장 조용하다.** 날짜 레일, 시계, 곁줄, 여백 라벨, 브레드크럼은 `Theme.recede ()`
단계다. 스크롤 방향타로는 남지만 밝은 자리를 쓰지 않는다.
- **실패는 이 규칙의 예외다.** `✗` 와 `failed` 절은 reset→bold→bright red 로 서고, 실패 줄은
접히지 않는다. 성공이 가라앉는 만큼 실패가 도드라져야 한다.
## 턴 레일 — `turn_rail_glyph`
레일은 "이 줄이 어느 턴에 속하나" 한 가지에만 답한다. 누가 말했나는 마크가 답한다.
한 기호에 두 질문을 싣지 않는다.
| 기호 | 값 | 뜻 |
|---|---|---|
| `╭` | `Rail_opens` | 턴이 이 줄에서 시작한다 |
| `│` | `Rail_says` | 턴 안의 말(운영자·키퍼·상태 등) |
| `├` | `Rail_does` | 턴 안의 작업(도구·스킬·생각) |
| `╶` | `Rail_stands` | 한 줄짜리 턴인데 그 줄이 작업이다 |
| `╰` | `Rail_closes` | 턴이 이 줄에서 끝났다 |
| `┤` | `Rail_joins` | 어느 턴에도 속하지 않은 줄이 선에 닿는다 |
| 빈칸 | `Rail_none` | 한 줄짜리 말, 또는 턴 밖의 줄 |
- `┤` 앞에는 세 칸짜리 곁줄이 붙는다. 저널은 점선 `╌╌╌`, 다른 사람이 보낸 줄은 실선 `───` 이다.
종류는 기호가 아니라 선 모양으로 말한다. 마크와 라벨이 이미 누구인지 말하기 때문이다.
- 괄호(`╭`…`╰`)는 **붙어 있는 줄에만** 친다. 턴 밖의 줄(저널 기록, 남의 broadcast)은 괄호를 끊지
않고 지나간다. 다른 턴의 줄이 끼어들어 한 턴이 둘로 갈라지면, 괄호 대신 외톨이 줄 두 개로 그린다.
한 칸짜리 세로줄로는 떨어진 줄을 한 턴이라고 말할 수 없다.
- 한 줄짜리 말은 레일을 그리지 않는다. 대화의 거의 모든 줄에 레일이 붙으면 아무도 레일을 안 본다.
## 왼쪽 여백의 순서는 고정이다
한 줄은 왼쪽부터 이 순서다. 순서를 바꾸면 세로줄이 어긋난다.
1. **레일 여백** — `turn_rail_cells = 5`. 곁줄 3칸 + 기호 1칸 + 띄움 1칸.
곁줄이 없는 줄도 5칸을 다 쓴다. 폭이 줄마다 달라지면 스크롤할 때 본문 줄바꿈이 바뀐다.
2. **시계** — `chat_clock_column = 5` 칸 + 띄움 1칸. `Origin_inline` 에서만 그린다.
모든 줄의 시계 칸은 같은 5칸이다. 칸 폭이 줄마다 다르면 본문 줄바꿈도 줄마다 달라진다.
시계는 recede 단계로 눌려 있고, 마크만 말한 사람의 색을 지킨다
(시계 칸의 경계는 `gutter_clock_cells`).
3. **마크 + 띄움 + 라벨** — 라벨 칸은 `chat_role_label_column = 10`. 마크 칸은 라벨 칸 안에서 뺀다.
이름이 넘치면 가운데를 줄인다(`fit_middle`). 마크는 줄이는 범위 밖에 있어서 긴 이름에서도 남는다.
도구·스킬·생각 줄은 각각 `TOOLS`·`SKILL`·`THINKING` 라벨을 함께 쓴다.
기본 화면에서 기호를 외우지 않아도 레인의 뜻을 읽을 수 있어야 한다.
4. **인용 막대** — 남이 쓴 글(`Tool`·`Skill`·`Status`·`Local`)은 흐린 `│`, `Journal` 은 `┊`,
말(`User`·`Inbound`·`Keeper`·`Error`·`Thinking`)은 빈칸 두 칸이다.
5. **본문**
폭이 모자라면 레일을 먼저 버린다. 그래도 이름이 안 들어가면 시계와 마크를 버리고 이름을 줄여 남긴다.
누가 말했는지가 언제 말했는지보다 중요하다.
### 같은 값을 반복해서 그리지 않는다
- 시계는 **분이 바뀐 줄에만** 그린다. 같은 분에 여섯 줄이 오면 한 줄만 시각을 달고 나머지는 빈칸이다.
칸은 그대로 비워 둬서 아무것도 밀리지 않는다.
- 같은 사람이 이어서 말하면 이름을 지운다. 마크도 가장 조용한 기호로 바뀐다
(`continued_mark`: `User`·`Inbound`·`Keeper`·`Tool` 은 `│`, `Thinking` 은 `·`, 나머지는 원래 마크).
## 밀도는 세 단계이고 Ctrl-F 로 돈다
| 단계 | 모양 |
|---|---|
| `Origin_inline` | 출처를 여백에 접고, 분이 바뀐 줄에 짧은 시계를 단다 |
| `Origin_row` | 헤더 한 줄: 이름 전체, 오른쪽 끝 recede 시계, 사이 rule |
- | `Origin_bare` (기본) | 시각·턴 시간 범위·시간 구분선·진행 타이머를 숨기고 마크와 이름을 남긴다 |
+ | `Origin_bare` (기본) | 시각·시간 구분선·진행 타이머를 숨기고 마크와 이름을 남긴다 |
Origin_row 헤더의 시각 칸이 비면(`timeline_bucket = None`) 시계를 안 그리고 `--:--:--`
자리표도 쓰지 않는다. 같은 화자가 이어지는 행은 이름 없이 rule 과 시계만 그린다
(`metadata_row`, `bin/masc_tui_render_chat.ml` 의 `origin_heading`).
Ctrl-F 는 `Origin_bare → Origin_inline → Origin_row → Origin_bare` 으로 돈다
(`Masc_tui_types.next_origin_display`). 기본값은 `Masc_tui_types` 의 상태 생성에서 정한다.
기본값 하나만으로 읽을 수 있어야 한다. "Ctrl-F 누르면 보인다" 는 기본값이 모자란다는 뜻이다.
- 연속한 요청 묶음의 첫 줄에는 요청 식별자를 표시한다. 실제 턴 번호가 기록된 줄이면
- `TURN #번호`를 함께 표시한다. 여러 입력을 한 실행이 처리하면 `입력 N건`을 붙인다.
- 식별자는 표시할 때만 줄인다. 그룹을 묶는 키는 원래의 전체 식별자다.
+ 사용자와 Keeper의 본문은 기록된 원문이다. 요청 식별자, 턴 번호, 처리 확인 문구,
+ 시도·시각 안내를 본문에 덧붙이지 않는다. 원문에 `요청`·`입력 반영됨` 같은 말이
+ 있으면 그대로 둔다. 그룹을 묶는 키는 전체 요청 식별자이며 표시용 축약값으로 바꾸지 않는다.
+ 시간은 여백·헤더에, 기술 식별자는 명시적으로 펼친 진단에 둔다.
+ 펼친 진단 줄은 본문이 다 끝난 뒤에 온다. 대기 입력도 펼쳤을 때 자기 요청 식별자를 진단 줄로 보인다.
아직 처리 시작을 확인하지 못한 입력은 `대기 입력 N건` 아래에 두고 `›` 마크를 쓴다.
- `대기`·`전송 중`·`접수됨`·`미확인`을 각 입력에 표시하며, 접수는 턴 반영을 뜻하지 않는다.
- `입력 반영됨`은 영속된 입력 또는 해당 실행의 `Run_started`를 확인한 경우에만 쓴다.
+ 각 입력의 원문 옆 라벨에 `전송 대기`·`전송 중`·`처리 대기`·`전송 확인 중`을 표시한다.
+ 서버가 입력을 받았다는 사실은 Keeper가 처리했다는 뜻이 아니다. 영속된 입력 또는
+ 해당 실행의 `Run_started`가 확인되면 일반 대화에 한 번만 표시하며 처리 확인 문구는
+ 붙이지 않는다. 거절 사유는 본문과 분리된 오류 행에 남긴다.
Thinking은 기본적으로 접어서 표시한다. `Ctrl-R`은 접힘 → 전체 → 숨김 순서로 돈다.
- Thinking·답변 델타를 실제로 받으면 진행줄에 `THINKING`·`STREAMING`을 표시한다.
+ Working은 턴이 진행 중이라는 뜻이다. Thinking·답변 델타를 실제로 받으면 진행줄에
+ `THINKING`·`STREAMING`을 표시한다.
## 끝난 일은 접힌다
끝난 일은 화면의 주인공이 아니다. 무엇이 남고 무엇이 접히는지는 정해져 있다.
- **끝난 스킬은 요약 한 줄이다.** `전달됨, 도구 씀 · 이름 · N action`
(`Keeper_chat_transcript.skill_rows`). 전달만 됐는지 실제로 쓰였는지는 기본 밀도에서
읽혀야 한다 — 이름만 남기는 접힘은 금지다.
- **`↳` 호출 줄, `proof · turn=… · use=…`, detail 줄은 Ctrl-D 뒤다.**
`msg_tool_visibility = Tools_full` 일 때만 그린다. 새 키를 만들지 않고 기존 도구 펼침 축에
탄다.
- **도구 블록은 기본 `Tools_compact` 다.** 반환된 호출은 inventory 헤더 한 줄로 접히고,
실패·미반환·진행 중 호출은 헤더의 절이 아니라 자기 마크의 독립 줄로 남는다 — 단 셋
이상 모였을 때만. 둘 이하면 쪼개도 Full 이 그리는 두 줄이라 접지 않고, 실패도 헤더가
`1 failed: web_fetch` 처럼 이름을 불러 한 줄이다 (`project_tool_block` Compact 팔,
`bin/masc_tui_keeper_chat_transcript.ml`).
- **Ctrl-D 는 `Tools_compact → Tools_results → Tools_full` 로 돈다.** `Tools_results` 는
호출 이름·받은 상태와 짧은 결과를 보여 준다. `↩` 는 결과를 받았다는 뜻이고 성공을 보장하지 않는다.
확정된 실패는 `✗` 로 남고, 결과 본문은 상태 색상 문법으로 해석하지 않는다.
- **돌아가는 동안에도 같은 토글로 그린다.** 라이브 블록과 확정된 블록은 같은
`project_tool_block` / `skill_rows` 를 지난다. 줄이 접히는 시점은 완료가 아니라
Ctrl-D 토글이다.
## 줄의 문구
- 게이트 줄은 가운뎃점으로 소식과 대상을 잇는다. 앞이 소식, 뒤가 대상이다
(`bin/masc_tui_gate_text.ml`: `승인됨 · 적용 예정`, `판정 중 · 이 호출은 미뤄짐`).
- 가운뎃점은 서로 다른 칸을 가르는 데만 쓴다. 한 상태에 사실이 둘이면 쉼표로 잇는다
(`bin/masc_tui_keeper_chat_transcript.ml`: `읽음, 전달 확인 중`, `전달됨, 도구 안 씀`).
`읽음 · 전달 기록 없음` 이라고 쓰면 상태 하나인지 둘인지 읽는 사람이 알 수 없다.
- 상태 이름은 모델이 무엇을 했는지로 쓴다(`읽음`, `전달됨`, `도구 씀`). 서버가 보내고 받은 동사로
쓰면 누가 누구에게 보냈는지 운영자가 알 수 없다.
- 한국어는 한국 사람이 실제로 쓰는 말로 쓴다. 영어 라벨을 품사째 옮기지 않는다.
- 한글 문구는 소스에 리터럴로 쓴다. 바이트 이스케이프(`\xeb\x83\xa8`)로 손으로 적지 않는다.
손으로 맞추다 틀리기 쉽다.
## 채팅 입력과 상태의 자리
- 탐색 제목은 대화 위에, runtime·context 값은 입력 프레임 아래에 둔다. 상태줄은 roster와 대화 패널 아래 전체 폭을 쓰며, 현재 대화 대상 Keeper를 이름으로 표시한다. roster 커서가 움직여도 대화 대상이 바뀌기 전에는 상태줄의 대상이 바뀌지 않는다.
- 슬래시 후보는 입력 위에서 명령 이름과 설명을 두 칸으로 보여 준다. 선택된 행만 강조한다.
- 방향키는 선택만 바꾼다. Tab·Enter는 초안에 넣고, 그 뒤 Enter가 기존 명령을 실행한다.
- Esc는 열린 메뉴를 먼저 닫는다. 메뉴가 보이지 않는 폭·높이에서는 입력을 가로채지 않는다.
- 메뉴는 대화 최소 높이를 보존한다. 전체 후보를 자르지 않고 선택 주위의 페이지를 보여 준다.
- 사용량은 관측된 값만 표시한다. 상한이 없으면 토큰 수만, 읽을 수 없으면 unavailable을 표시한다.
## 고치고 나면
- 화면은 코드를 읽어서 판정하지 않는다. `tui-pty-scenario` 스킬로 실제로 띄워 본다.
- 문구·기호·폭을 바꿨으면 `test/*.py` 가 기다리는 글자열(needle)에서 옛 문구와 옛 기호의 바이트열을
찾아 같이 고친다. 렌더 파일만 고친 PR 은 PR CI 가 키보드 산책을 돌리지 않는다.
- 하네스가 못 보는 것은 사람에게 묻는다: 색, 사용자 글꼴이 `╶ ╌ ┊ ◈ ○` 를 그리는지, 실제 폭에서 읽히는지.