diff --git a/.agents/rules/commits.md b/.agents/rules/commits.md index e61c0ed1..d3de5d7b 100644 --- a/.agents/rules/commits.md +++ b/.agents/rules/commits.md @@ -1,47 +1,30 @@ ## Commit Units -- 개발 작업은 가능한 한 작은 독립 단위로 나눈다. -- 하나의 commit은 하나의 명확한 목적만 담는다. -- commit은 가능하면 독립적으로 리뷰 가능해야 한다. -- commit은 가능하면 독립적으로 되돌릴 수 있어야 한다. -- 큰 작업도 여러 개의 작은 commit으로 나눠 진행한다. -- 인터페이스 정의, 구현체 추가, 테스트 추가, 문서 반영은 가능한 분리하되, 하나의 의미 있는 작업 단위가 깨질 정도로 과하게 쪼개지 않는다. +- 하나의 commit은 하나의 목적만 담고, 독립적으로 리뷰·revert 가능해야 한다. +- 큰 작업도 작은 commit으로 나눈다. 단, 의미 있는 작업 단위가 깨질 정도로 쪼개지 않는다. +- 각 commit 시점에 빌드와 테스트가 통과해야 한다 (AGENTS.md의 Verify 게이트). -## Feature-scoped Commit Workflow +## Feature-scoped Workflow -- 구현은 기능 단위(feature scope)로 나눈다. -- 각 기능 단위는 구현을 먼저 커밋하고, 테스트는 별도 커밋으로 분리할 수 있다. 단, 인터페이스 변경 시에는 인터페이스 정의와 해당 contract test를 같은 커밋에 포함한다 (testing.md 선행 규칙 준수). +- 구현을 먼저 커밋하고 테스트는 별도 commit으로 분리할 수 있다. 단, 인터페이스를 바꿀 때는 + 인터페이스 정의와 그 contract test를 같은 commit에 넣는다 (`testing.md`의 선행 규칙). - 기능 단위 간 의존성이 있으면 의존되는 쪽을 먼저 커밋한다. -- 각 커밋 후 빌드와 기존 테스트가 통과해야 한다. +- 코드와 직접 연결된 문서 변경은 같은 commit 또는 바로 이어지는 commit에 넣는다. ## Message Format -- commit message 형식은 `type: commit message`로 통일한다. -- 권장 type: `feat`, `fix`, `refactor`, `test`, `docs`, `chore` -- `commit message`는 무엇을 바꿨는지 짧고 구체적으로 적는다. -- 메시지에는 실제 변경 내용을 담는다. 작업 과정이나 도구 이름을 메시지로 쓰지 않는다 (예: ✗ "codex review 반영", ✗ "리뷰 수정", ✓ "리뷰 중단 기준에 순환 판단 조건 추가"). +- `type: message` 또는 `type(scope): message`. type은 `feat`, `fix`, `refactor`, `test`, `docs`, `chore`. +- 무엇을 바꿨는지 짧고 구체적으로 쓴다. +- 작업 과정이나 도구 이름을 메시지에 쓰지 않는다 + (✗ "codex review 반영", ✗ "리뷰 수정", ✓ "리뷰 중단 기준에 순환 판단 조건 추가"). -## Pre-commit Checks +## History -- commit 전에는 빌드 검증과 해당 변경 범위 테스트를 수행한다. +- 작업이 끝나기 전에도 의미 있는 milestone마다 commit을 남긴다. +- commit history만 읽어도 구현 순서와 의도를 따라갈 수 있어야 한다. +- 나중에 squash할 생각의 임시 잡탕 commit보다 읽히는 history를 우선한다. -## History Quality +## Branch -- 작업 완료 전에도 의미 있는 하위 milestone마다 commit을 남긴다. -- 구현 순서가 commit history만 봐도 따라갈 수 있게 유지한다. -- 문서 변경이 코드 변경과 직접 연결되면 같은 commit 또는 바로 이어지는 commit으로 남긴다. -- 작업 종료 시 commit history만 읽어도 구현 순서와 의도를 따라갈 수 있어야 한다. -- 나중에 squashing을 기대한 임시 잡탕 commit보다 읽히는 history를 우선한다. - -## Branch Strategy - -- feature 단위로 브랜치를 생성한다. -- 브랜치 네이밍: `type/short-description` (e.g., `feat/user-auth`, `fix/token-expiry`) -- type은 commit message와 동일한 set을 사용한다: feat, fix, refactor, test, docs, chore -- main에 직접 커밋은 문서만 변경하거나 설정 수정 등 단순 변경에 한한다. - -## Prohibited - -- 하나의 commit에는 하나의 목적에 해당하는 변경만 포함한다. -- 각 작업 단위는 별도 commit으로 분리한다. -- 완료 commit은 빌드와 테스트가 통과하는 상태여야 한다. +- 브랜치 네이밍: `type/short-description` (예: `feat/user-auth`, `fix/token-expiry`). type set은 위와 같다. +- 기본 브랜치(`dev`)에 직접 커밋은 문서·설정 등 단순 변경에 한한다. diff --git a/.agents/rules/dependencies.md b/.agents/rules/dependencies.md index e813779c..26db7ab0 100644 --- a/.agents/rules/dependencies.md +++ b/.agents/rules/dependencies.md @@ -1,30 +1,13 @@ -## Library Selection +## Selecting -- 구현 계획 수립 시 필요한 라이브러리를 조사하고, 선정 결과를 설계 문서(architecture 등)에 확정한다. -- 후보 라이브러리를 비교할 때 **웹 검색으로 최신 정보를 확인한다**. 지식 컷오프 기준의 정보만으로 판단하지 않는다. - - 공식 SDK 또는 공식 권장 라이브러리인지 - - 커뮤니티 채택도 (GitHub stars, 주간 다운로드 수, 사용 사례) — 검색으로 현재 수치 확인 - - 프로젝트의 언어/프레임워크와의 호환성 - - 최근 릴리스 활동과 이슈 대응 상태 — 저장소 또는 패키지 페이지에서 확인 -- 공식 라이브러리 또는 커뮤니티에서 널리 채택된 라이브러리를 우선 선택한다. -- 선정 근거(왜 이 라이브러리인지, 비교한 대안)를 문서에 함께 기록한다. +- 새 의존성 전에 stdlib 또는 이미 있는 의존성으로 되는지 먼저 확인한다. +- 후보 비교는 **웹 검색으로 현재 정보를 확인한다**. 지식 컷오프 기준으로 판단하지 않는다. + 공식/권장 여부, 채택도, 최근 릴리스·이슈 대응, 라이선스, transitive dependency 규모를 본다. +- 공식 또는 널리 채택된 쪽을 우선한다. +- 선정 근거와 비교한 대안을 `docs/architecture.md`에 기록한다. -## Adding New Dependencies +## Updating / Removing -- 새 의존성을 추가하기 전에 stdlib 또는 기존 의존성으로 해결 가능한지 먼저 확인한다. -- 의존성 추가 시 다음을 평가한다: - - 유지보수 상태 (최근 릴리스, 이슈 대응 속도) - - 라이선스 호환성 - - 패키지 크기와 transitive dependency 수 - - 프로젝트에서 실제로 사용할 기능 대비 패키지 전체 크기 - -## Updating Dependencies - -- 의존성 업데이트 시 breaking change를 반드시 확인한다. -- major 버전 업데이트는 changelog을 읽고 migration guide가 있으면 따른다. -- 업데이트 후 빌드와 테스트가 통과하는지 확인한다. - -## Removing Dependencies - -- 더 이상 사용하지 않는 의존성은 제거한다. -- 제거 전 프로젝트 내 실제 사용처가 없는지 검색으로 확인한다. +- 업데이트 시 breaking change를 확인한다. major는 changelog과 migration guide를 읽는다. +- 업데이트 후 빌드와 테스트 통과를 확인한다. +- 쓰지 않는 의존성은 제거한다. 제거 전 실제 사용처가 없는지 검색으로 확인한다. diff --git a/.agents/rules/docs.md b/.agents/rules/docs.md index dcd43ed9..2e13314a 100644 --- a/.agents/rules/docs.md +++ b/.agents/rules/docs.md @@ -1,33 +1,20 @@ -## 문서 유형 +## What Exists -- 프로젝트에는 다음 문서 중 해당하는 것만 유지한다: - - **README**: 프로젝트 목적, 시작 방법 (설치, 실행), 주요 명령어 - - **Architecture 문서**: 계층 구조, 모듈 책임, 핵심 설계 결정과 그 이유 - - **API 문서**: 공개 인터페이스의 입력, 출력, 에러, 사용 예시 -- 프로젝트에 필요 없는 문서 유형을 만들지 않는다. +- `README.md` — 무엇인지, 설치·실행, 사전 조건. 처음 보는 사람이 5분 안에 로컬 실행 가능한 수준. +- `docs/architecture.md` — 계층 구조, 모듈 책임, 핵심 설계 결정과 그 이유. +- `docs/` 나머지 — 기능별 사용 문서. +- 필요 없는 문서 유형을 새로 만들지 않는다. 유지보수할 수 없는 문서는 만들지 않는다. +- 형식적으로 빈 섹션(Contributing, License 등)을 채우지 않는다. -## README +## What to Document -- README는 프로젝트를 처음 접하는 사람이 5분 안에 로컬에서 실행할 수 있는 수준을 목표로 한다. -- 최소 포함 항목: 프로젝트가 무엇인지 (1-2문장), 설치/실행 방법, 환경 변수나 사전 조건. -- 사용하지 않는 섹션(Contributing, License 등)을 형식적으로 채우지 않는다. +- 공개 인터페이스의 계약: 입력, 출력, 에러, 부작용. +- 비자명한 제약: 순서 의존성, 호출 전제 조건, 스레드/동시성 안전성. +- 코드가 표현하지 못하는 맥락과 "왜". +- 내부용 함수는 이름과 시그니처가 명확하면 문서화하지 않는다. 타입이 말하는 것을 주석으로 반복하지 않는다. -## API 및 인터페이스 문서 +## Quality -- 공개 함수, 클래스, 모듈의 계약(입력, 출력, 에러, 부작용)을 문서화한다. -- 내부 구현용 함수는 이름과 시그니처가 명확하면 별도 문서화하지 않는다. -- 타입 시스템이 표현하는 정보를 주석으로 반복하지 않는다. -- 비자명한 제약 조건(순서 의존성, 호출 전제 조건, 스레드 안전성)은 반드시 문서화한다. - -## 문서 품질 - -- 문서는 현재 코드와 일치해야 한다. 틀린 문서는 문서가 없는 것보다 나쁘다. -- 코드 변경으로 문서 내용이 달라지면 같은 작업 안에서 문서를 갱신한다. -- 예시 코드가 있으면 실제로 실행 가능한 상태를 유지한다. -- 추측이나 미래 계획을 사실처럼 기술하지 않는다. - -## 금지 사항 - -- 자동 생성된 boilerplate 문서는 생성 설정을 통해 수정한다. -- 문서는 코드가 표현하지 못하는 맥락과 이유를 담는다. -- 생성하는 문서는 지속적으로 유지보수 가능한 것만 만든다. +- 틀린 문서는 없는 문서보다 나쁘다. 코드 변경으로 내용이 달라지면 같은 작업 안에서 갱신한다. +- 예시 코드는 실제로 실행 가능한 상태를 유지한다. +- 추측이나 미래 계획을 사실처럼 쓰지 않는다. diff --git a/.agents/rules/guardrails.md b/.agents/rules/guardrails.md index 65fdd9c3..23ede219 100644 --- a/.agents/rules/guardrails.md +++ b/.agents/rules/guardrails.md @@ -1,48 +1,29 @@ -## Official Sources +## File Size -- 외부 라이브러리나 SDK의 동작이 불확실할 때 공식 문서와 소스를 직접 확인한다. -- 추측보다 소스 코드 확인을 우선한다. +- 모든 소스 파일(Rust, TypeScript, TSX, JavaScript)은 300줄 이하다. 테스트 파일도 예외 없다. +- 200줄 이상은 code smell이다. 분할을 검토한다. +- 분할은 동작을 바꾸지 않는 순수 리팩토링이어야 한다. 모듈, 순수 함수, 컴포넌트/훅으로 쪼갠다. +- 생성물(`target/`, `viewer-ui/dist/`)과 벤더링한 서드파티는 제외한다. ## Architecture -- 프로젝트에 architecture 문서가 있으면 설계 결정의 기준으로 삼는다. -- 문서화된 설계 전제를 구현의 기준으로 유지한다. +- `docs/architecture.md`가 설계 결정의 기준이다. 구현이 문서와 어긋나면 문서를 먼저 고치거나 구현을 조정한다. +- top-level 구조는 새 모듈을 붙일 수 있도록 열어 두되, 초기 구현은 간소하게 시작한다. ## Code Quality -- 가장 단순한 해결책을 먼저 시도한다. 복잡한 추상화는 반복이 실제로 발생한 후에 도입한다. -- 하나의 함수/모듈은 하나의 책임만 갖는다. 책임이 섞이면 분리한다. -- 매직 넘버, 하드코딩된 문자열은 이름 있는 상수로 추출한다. -- 이해하기 어려운 로직에만 주석을 단다. "무엇"이 아니라 "왜"를 설명한다. - -## Performance - -- 알고리즘 복잡도를 의식한다. O(n²) 이상이면 의도적 선택인지 확인한다. -- hot path에서는 할당, 복사, 변환을 최소화한다. -- 쿼리는 배치로 처리하여 N+1 패턴을 방지한다. -- 성능 최적화는 측정 후에 한다. 추측 기반 최적화를 하지 않는다. +- 가장 단순한 해결책을 먼저 시도한다. 추상화는 반복이 실제로 발생한 후에 도입한다. +- 하나의 함수/모듈은 하나의 책임만 갖는다. +- 매직 넘버와 하드코딩 문자열은 이름 있는 상수로 뽑는다. +- 주석은 "왜"만 남긴다. 타입이 이미 말하는 것은 반복하지 않는다. ## Error Handling -- 에러는 처리하거나 명시적으로 전파한다. -- 에러 메시지는 디버깅에 충분한 컨텍스트를 포함한다 (무엇이 실패했고, 어떤 입력이었는지). -- 복구 가능한 에러와 복구 불가능한 에러를 구분하여 처리한다. -- 외부 시스템 호출 실패 시 재시도 여부와 전략을 명시적으로 결정한다. - -## External Integration +- 에러는 처리하거나 명시적으로 전파한다. 조용히 삼키지 않는다. +- 에러 메시지에 무엇이 실패했고 입력이 무엇이었는지를 담는다. +- 복구 가능/불가능을 구분하고, 외부 호출 실패는 재시도 여부를 명시적으로 결정한다. -- 외부 시스템과의 인터페이스는 공식 문서와 SDK를 우선으로 맞춘다. -- 공식 helper/wrapper가 있으면 우선 사용한다. -- 외부 시스템 인터페이스는 공식 SDK와 표준 패턴을 사용한다. +## Externals -## Extensibility - -- 초기 구현은 간소화하되, 이후 확장 가능한 구조를 유지한다. -- top-level 구조는 새로운 모듈 추가가 가능하도록 범용적으로 유지한다. - -## File Size - -- 모든 소스 파일(Rust, TypeScript, TSX, JavaScript)은 300줄 이하다. 테스트 파일도 예외 없다. -- 200줄 이상은 code smell이다. 분할을 검토한다. -- 분할은 동작을 변경하지 않는 순수 리팩토링이어야 한다. 커스텀 훅, 컴포넌트, 순수 함수 모듈로 쪼갠다. -- 생성된 파일(`dist/`, `target/`)과 벤더링한 서드파티는 제외한다. +- 외부 라이브러리·SDK의 동작이 불확실하면 추측하지 말고 공식 문서와 소스를 직접 확인한다. +- 렌더 루프 등 hot path에서는 할당·복사·변환을 최소화한다. 그 밖의 최적화는 측정 후에 한다. diff --git a/.agents/rules/security.md b/.agents/rules/security.md index 6571d55e..cd05660b 100644 --- a/.agents/rules/security.md +++ b/.agents/rules/security.md @@ -1,23 +1,19 @@ ## Input Validation -- 시스템 경계(사용자 입력, 외부 API 응답, 파일 읽기)에서 들어오는 데이터는 항상 검증한다. +- 시스템 경계(사용자 입력, 외부 API 응답, 파일 읽기, HTTP 요청)에서 오는 데이터는 항상 검증한다. - 내부 모듈 간 호출에서는 과도한 방어적 검증을 피한다. 계약을 신뢰한다. -## Secrets Management - -- 비밀번호, API 키, 토큰은 환경 변수나 별도 설정 파일을 통해 주입한다. -- `.env`, credentials 파일은 `.gitignore`에 포함하고 절대 커밋하지 않는다. - -## Logging +## Secrets +- 비밀번호, API 키, 토큰은 환경 변수나 별도 설정 파일로 주입한다. +- `.env`, credentials 파일은 `.gitignore`에 두고 절대 커밋하지 않는다. - 로그의 민감 필드(비밀번호, 토큰, 개인식별정보)는 마스킹한다. -- 에러 로그에는 디버깅에 필요한 컨텍스트를 충분히 포함한다. -## Dependencies +## Least Privilege -- 의존성 추가 시 알려진 취약점(CVE) 여부, 유지보수 상태, 보안 이력을 확인한다. +- 파일, 네트워크, API 접근 권한은 필요한 최소 범위로 제한한다. +- 로컬 소켓·HTTP 서버는 필요 이상으로 넓은 인터페이스에 바인드하지 않는다. -## Principle of Least Privilege +## Dependencies -- 파일, 네트워크, API 접근 권한은 필요한 최소 범위로 제한한다. -- 권한은 작업에 필요한 최소 수준만 요청한다. +- 의존성 추가 시 알려진 CVE, 유지보수 상태, 보안 이력을 확인한다. diff --git a/.agents/rules/testing.md b/.agents/rules/testing.md index 6c71c8be..7840ce51 100644 --- a/.agents/rules/testing.md +++ b/.agents/rules/testing.md @@ -1,37 +1,23 @@ -## Test Layers +## Which Layer -- unit tests — 순수 함수, 개별 모듈 로직 -- contract tests — 모듈 간 계약(인터페이스) 검증 -- integration tests — API endpoint, 전체 요청 흐름 -- end-to-end tests — 사용자 관점의 시나리오 검증 - -## When to Use Which - -- 모듈의 계약(인터페이스)을 추가/변경할 때 → contract test 필수 -- 순수 함수, 개별 로직을 추가/변경할 때 → unit test -- API endpoint, middleware, 전체 요청 흐름을 추가/변경할 때 → integration test -- 하나의 변경이 여러 유형에 해당하면 각각 작성한다 - -## File Placement - -- 테스트 파일 배치와 네이밍은 프로젝트의 기존 컨벤션을 따른다. -- test fixtures/helpers가 여러 테스트에서 공유되면 공통 디렉토리에 둔다. - -## Principles - -- 테스트는 구현 세부사항이 아니라 계약과 동작을 검증한다. -- mock은 외부 시스템 경계(외부 API, 네트워크 호출)에만 사용한다. -- 테스트 이름은 `무엇을_하면_어떤_결과가_나온다` 패턴으로 의도를 명확히 한다. -- 각 테스트는 독립적으로 실행 가능해야 한다. 테스트 간 상태 공유는 금지한다. +- 모듈 간 계약(인터페이스)을 추가/변경 → **contract test 필수** +- 순수 함수, 개별 모듈 로직 → unit test +- API endpoint, 요청 흐름 전체(web viewer, daemon protocol) → integration test +- 사용자 관점 시나리오 → end-to-end test +- 하나의 변경이 여러 유형에 걸치면 각각 작성한다. ## Rules - contract test를 먼저 갱신하지 않고 인터페이스를 바꾸지 않는다. -- 에러 케이스와 경계 조건(null, empty, 범위 초과, 잘못된 타입)을 명시적으로 테스트한다. -- 성공 경로뿐 아니라 실패 경로도 테스트한다. +- 테스트는 구현 세부사항이 아니라 계약과 동작을 검증한다. +- 성공 경로만이 아니라 실패 경로와 경계 조건(null, empty, 범위 초과, 잘못된 타입)도 명시적으로 테스트한다. +- mock은 외부 시스템 경계에만 쓴다. +- 각 테스트는 독립 실행 가능해야 한다. 테스트 간 상태 공유 금지. +- 테스트 이름은 `무엇을_하면_어떤_결과가_나온다` 패턴으로 의도를 드러낸다. +- 배치·네이밍은 기존 컨벤션을 따른다. 공유 fixture/helper는 공통 위치에 둔다 (`src/test_util.rs`). ## Flaky Tests -- 테스트 실패 시 원인을 먼저 분류한다: 코드 결함 vs 환경/타이밍 문제. -- flaky 테스트를 발견하면 즉시 수정하거나, 수정 전까지 skip 처리하고 이슈로 기록한다. -- flaky 테스트를 이유로 전체 테스트 결과를 무시하지 않는다. +- 실패하면 원인을 먼저 분류한다: 코드 결함 vs 환경/타이밍. +- flaky를 발견하면 즉시 고치거나, 고치기 전까지 skip하고 이슈로 남긴다. +- flaky를 이유로 전체 테스트 결과를 무시하지 않는다. diff --git a/.agents/rules/token-optimization.md b/.agents/rules/token-optimization.md deleted file mode 100644 index 55d96b19..00000000 --- a/.agents/rules/token-optimization.md +++ /dev/null @@ -1,28 +0,0 @@ -## Token Optimization - -토큰은 품질을 떨어뜨리지 않는 범위에서 절약한다. 목표는 "작은 컨텍스트, 동일한 결정 품질"이다. - -## 기본 원칙 - -- 긴 원문 인용 대신 요약을 우선한다. -- 규칙/문서/코드 탐색은 필요한 파일만 연다. -- 같은 정보를 반복 출력하지 않는다. -- 계획/리뷰는 변경 범위 기준으로만 작성한다. - -## 실행 규칙 - -- 대규모 변경은 작은 단위로 분할하고, 각 단위 완료 후 context를 요약한다. -- 파일 읽기는 전체 출력보다 `rg`/부분 조회를 우선한다. -- 리뷰 결과는 `severity + 근거 + 조치` 3요소로 압축한다. -- 불필요한 재탐색을 막기 위해 결정 사항은 문서 또는 memory에 즉시 기록한다. - -## 금지 - -- 전체 저장소를 무차별적으로 읽는 탐색 -- 동일 실패 로그를 반복 재출력하는 루프 -- 이미 확정된 결정을 매 단계에서 다시 비교하는 행동 - -## Completion Check - -- 작업 완료 보고에는 핵심 변경, 검증 결과, 남은 리스크만 포함한다. -- 상세 로그는 요청이 있을 때만 확장한다. diff --git a/.agents/skills/_shared/review-protocol.md b/.agents/skills/_shared/review-protocol.md index 8de960e1..e04c6587 100644 --- a/.agents/skills/_shared/review-protocol.md +++ b/.agents/skills/_shared/review-protocol.md @@ -29,7 +29,8 @@ ## 3. 수정 적용 -- 수정 후 프로젝트의 빌드와 테스트를 실행해 다른 것이 깨지지 않았는지 확인한다. +- 수정 후 AGENTS.md의 Verify 게이트(`cargo build`, `cargo test`, `cargo clippy --all-targets + --all-features -- -D warnings`)를 실행해 다른 것이 깨지지 않았는지 확인한다. - 테스트가 실패하면 원인을 먼저 분류한다. - 수정이 원인: 수정을 되돌리고 **사용자 판단 필요**로 재분류한다. - 기존 flaky 또는 환경 문제: 수정을 유지하고 실패 원인을 보고한다. diff --git a/.agents/skills/plan/SKILL.md b/.agents/skills/plan/SKILL.md index 10fd3e60..2f1edc18 100644 --- a/.agents/skills/plan/SKILL.md +++ b/.agents/skills/plan/SKILL.md @@ -6,52 +6,33 @@ user-invocable: true # Plan -구현을 시작하기 전에 요구사항을 분석하고, 설계 대안을 비교하고, 구현 계획을 수립한다. -사용자와 계획을 정렬한 후 구현을 진행한다. - ## 1. 요구사항 분석 -- 사용자의 요청을 읽고 핵심 목표와 제약을 정리한다. -- 인자가 전달되면 (`/plan `) 해당 내용을 작업 설명으로 사용한다. -- 프로젝트의 architecture 문서, rules가 있으면 읽어서 현재 맥락을 파악한다. -- 관련된 기존 코드를 탐색하여 현재 구조와 패턴을 이해한다. - -### 정리할 항목 -- **목표**: 이 작업이 달성해야 하는 것 -- **제약**: 건드리면 안 되는 것, 유지해야 하는 것 -- **영향 범위**: 변경이 미치는 파일/모듈 범위 -- **불확실한 점**: 추가 확인이 필요한 사항 +- 인자가 전달되면 (`/plan `) 그 내용을 작업 설명으로 쓴다. +- `docs/architecture.md`와 `.agents/rules/`를 읽어 제약을 확인하고, 관련 기존 코드를 탐색해 + 현재 구조와 패턴을 파악한다. +- 정리할 항목: **목표** / **제약**(건드리면 안 되는 것) / **영향 범위**(파일·모듈) / **불확실한 점**. ## 2. 설계 대안 비교 -변경이 단순하지 않을 때 (3개 이상의 파일 수정 또는 설계 판단이 필요한 경우), 실행 가능한 접근 방식을 2-3개 도출하고 비교한다. +3개 이상 파일을 고치거나 설계 판단이 필요할 때만 한다. 단순한 변경이면 건너뛴다. -각 대안에 대해: -- 핵심 아이디어 (1-2문장) -- 장점 -- 단점 / 리스크 -- 영향받는 파일/모듈 - -변경이 단순하면 이 단계를 건너뛰고 바로 구현 계획으로 넘어간다. +접근 방식 2-3개를 도출하고 각각에 대해: 핵심 아이디어(1-2문장), 장점, 단점/리스크, +영향받는 파일·모듈. ## 3. 구현 계획 -선택한 접근 방식을 구체적인 작업 단위로 분해한다. - -### 계획에 포함할 항목 -- 작업 순서 (의존성 고려) -- 각 단계에서 변경할 파일/모듈 -- 각 단계의 검증 방법 (테스트, 빌드) -- 예상되는 커밋 단위 -- 새로 도입할 라이브러리가 있으면 후보를 조사하고, 선정 근거와 함께 문서에 확정한다 (dependencies.md "Library Selection" 참조) +선택한 방식을 작업 단위로 분해한다. 각 단위마다 변경할 파일·모듈, 검증 방법, 예상 commit +단위(`commits.md`)를 적는다. -### 계획 수립 시 원칙 - 의존되는 쪽을 먼저 구현한다. -- 각 단계가 끝나면 빌드와 테스트가 통과하는 상태여야 한다. +- 각 단계가 끝난 시점에 AGENTS.md의 Verify 게이트가 통과하는 상태여야 한다. +- 새 라이브러리를 도입하면 `.agents/rules/dependencies.md`의 선정 절차를 따르고 근거를 + `docs/architecture.md`에 남긴다. ## 4. 사용자 확인 -계획을 사용자에게 다음 형식으로 보고하고 구현 전 정렬한다. +계획을 다음 형식으로 보고하고 구현 전 정렬한다. ### 목표 및 제약 (정리된 목표와 제약) diff --git a/.agents/skills/security-review/SKILL.md b/.agents/skills/security-review/SKILL.md index bd0adb72..13e7698d 100644 --- a/.agents/skills/security-review/SKILL.md +++ b/.agents/skills/security-review/SKILL.md @@ -1,52 +1,39 @@ --- name: security-review -description: 구현 완료 후 보안 관점 심층 리뷰 — 즉시 반영 항목을 수정하고 결과를 보고한다. clean pass까지 반복이 필요하면 ralph를 통해 실행한다 +description: 구현 완료 후 보안 관점 심층 리뷰 — 즉시 반영 항목을 수정하고 결과를 보고한다 user-invocable: true --- # Security Review -구현 완료 후 변경 사항을 보안 관점에서 심층 분석하고, 타당한 보안 개선을 코드에 반영한다. -반복 실행이 필요하면 `/ralph /security-review`로 ralph에 위임한다. +절차(대상 수집, 수정 적용, 중단, 보고)는 `.agents/skills/_shared/review-protocol.md`를 읽고 +그대로 따른다. 이 문서는 무엇을 볼지만 정한다. -대상 수집, 수정 적용, 중단, 보고 절차는 `.agents/skills/_shared/review-protocol.md`를 -읽고 그대로 따른다. 이 문서는 무엇을 볼지만 정한다. - -추가로 읽을 것: `.agents/rules/security.md` — 프로젝트가 이미 정한 보안 규칙. +`.agents/rules/security.md`를 함께 읽는다 — 프로젝트가 이미 정한 보안 규칙. ## 분석 렌즈 (extended thinking) -`security.md`가 정한 규칙의 준수 여부를 변경된 코드에서 확인하고, 그 위에 다음을 본다. +먼저 `security.md`의 규칙 준수 여부를 변경된 코드에서 확인하고, 그 위에 다음을 본다. -### 입력 검증 -- 시스템 경계(사용자 입력, 외부 API 응답, 파일, URL 파라미터, 헤더)의 검증 여부. -- SQL/Command Injection, XSS, Path Traversal 등 OWASP Top 10 노출 경로. +### 입력과 주입 +- 시스템 경계(사용자 입력, 외부 API 응답, 파일 경로, URL 파라미터, 헤더)의 검증 여부. +- Command Injection, XSS, Path Traversal 등 OWASP Top 10 노출 경로. - 신뢰할 수 없는 입력의 역직렬화. ### 인증 및 권한 -- 인증 로직의 우회 가능성. -- 권한 검사가 누락된 엔드포인트나 기능. -- 세션/토큰의 만료, 무효화, 저장 방식. -- 권한 상승 경로. - -### 비밀 정보와 데이터 보호 -- 키/비밀번호/토큰의 하드코딩. -- 민감 정보가 로그, 에러 메시지, 응답 본문, 커밋 히스토리에 노출되는지. -- PII·금융 정보의 암호화/마스킹, 전송 채널(TLS). - -### 의존성 -- 새 의존성의 알려진 CVE, 유지보수 상태, 보안 이력. -- 불필요하게 넓은 권한을 요구하는 의존성. - -### 최소 권한 -- 파일, 네트워크, API 접근이 필요한 최소 범위인지. -- root/admin 권한을 요구하는 구현. -- CORS, CSP 등 브라우저 보안 정책 설정. +- 인증 우회 가능성, 권한 검사가 빠진 엔드포인트·명령. +- 세션/토큰의 만료, 무효화, 저장 방식. 권한 상승 경로. -### 에러 처리와 정보 노출 -- 에러 응답의 내부 세부사항(스택 트레이스, DB 스키마, 내부 경로) 노출. +### 정보 노출 +- 키/토큰 하드코딩. 민감 정보가 로그, 에러 메시지, 응답 본문, 커밋 히스토리에 새는지. +- 에러 응답의 내부 세부사항(스택 트레이스, 내부 경로) 노출. - 에러 처리가 보안 검사를 우회하는 경로를 만드는지. +### 노출면 +- 로컬 daemon 소켓과 web viewer의 바인드 주소·권한이 필요한 최소인지. +- CORS, CSP 등 브라우저 보안 정책 설정. +- root/admin 권한을 요구하는 구현, 불필요하게 넓은 권한을 요구하는 의존성. + ## 분류 경계 - **즉시 반영**: 보안 취약점, 민감 정보 노출, 인증/권한 우회, 입력 검증 누락. diff --git a/.agents/skills/self-review/SKILL.md b/.agents/skills/self-review/SKILL.md index 08c8a0a9..8535193e 100644 --- a/.agents/skills/self-review/SKILL.md +++ b/.agents/skills/self-review/SKILL.md @@ -1,18 +1,15 @@ --- name: self-review -description: 구현 완료 후 thinking mode 심층 리뷰 — 즉시 반영 항목을 수정하고 결과를 보고한다. clean pass까지 반복이 필요하면 ralph를 통해 실행한다 +description: 구현 완료 후 thinking mode 심층 리뷰 — 즉시 반영 항목을 수정하고 결과를 보고한다 user-invocable: true --- # Self Review -구현 완료 후 thinking mode로 변경 사항을 심층 분석하고 타당한 개선을 코드에 반영한다. -반복 실행이 필요하면 `/ralph /self-review`로 ralph에 위임한다. +절차(대상 수집, 수정 적용, 중단, 보고)는 `.agents/skills/_shared/review-protocol.md`를 읽고 +그대로 따른다. 이 문서는 무엇을 볼지만 정한다. -대상 수집, 수정 적용, 중단, 보고 절차는 `.agents/skills/_shared/review-protocol.md`를 -읽고 그대로 따른다. 이 문서는 무엇을 볼지만 정한다. - -추가로 읽을 것: 현재 작업의 scope 문서가 있으면 함께 읽어 범위를 확인한다. +현재 작업의 scope 문서가 있으면 함께 읽어 범위를 확인한다. ## 분석 렌즈 (extended thinking) diff --git a/Cargo.toml b/Cargo.toml index 20a4b403..cbc90f7f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,7 @@ categories = ["command-line-utilities", "development-tools"] # The published crate needs the viewer's *built* bundle (rust-embed reads # viewer-ui/dist at compile time) but not its sources or toolchain files — # those live in git. Keeps the crate small without breaking `cargo install`. -# Repo-only tooling (agent rules, CI, hooks, planning notes) is dropped too. +# Repo-only tooling (agent rules, CI, hooks) is dropped too. exclude = [ "viewer-ui/node_modules", "viewer-ui/src", @@ -33,10 +33,6 @@ exclude = [ ".githooks", "release.toml", "CLAUDE.md", - "docs/code-review-2026-05-08.md", - "docs/repo-picker-plan.md", - "docs/status-short-plan.md", - "docs/web-viewer-plan.md", ] [dependencies] @@ -71,7 +67,7 @@ signal-hook = "0.4" # `flock` for the daemon's single-instance lock — the one POSIX call std does # not expose. Already in the tree through several dependencies, so this only # names it directly; the daemonize crates were not adopted (see -# docs/session-daemon-plan.md). +# docs/decisions.md). libc = "0.2" [dev-dependencies] diff --git a/README.md b/README.md index 5cda3b1e..f814bf30 100644 --- a/README.md +++ b/README.md @@ -20,699 +20,71 @@ nightcrow runs as a **session**: one process holds the repositories and the term ## Install -Install straight from the repository (the built viewer bundle is committed, so -this needs no Node toolchain): +The built viewer bundle is committed, so this needs no Node toolchain: ```bash cargo install --git https://github.com/code0xff/nightcrow --locked ``` -Or build a local checkout: +Requires Rust 1.85+ (edition 2024). Other install routes are in +[Getting started](docs/getting-started.md#install). -```bash -cargo install --path . --locked -``` - -Once published to crates.io this will also work: +## Quick start ```bash -cargo install nightcrow --locked -``` - -Requires Rust 1.85+ (edition 2024). `--locked` builds against the committed -`Cargo.lock` for a reproducible install. - -## Usage - -```bash -# Start the session. Runs in the foreground until you stop it (Ctrl-C). -# It reopens the repositories from last time — nothing, on a first run. +# Start the session (foreground; -d to background it). +# It reopens the repositories from last time. nightcrow -# ...or run it in the background and get your shell back. -nightcrow -d - # From another terminal: bring up the TUI on that session. nightcrow attach - -# Launch terminal panes running commands at startup (repeatable) -nightcrow --exec "claude" --exec "codex" -``` - -The session prints the address of its browser view (`http://127.0.0.1:8091/` -by default) and the socket an attaching terminal uses. Both show the same -repositories; open one with ` o` in the TUI or the folder picker in the -browser, and it appears in the other. There is no flag for opening a -repository — a session several clients share has one sensible place to do it, -and that is inside. - -If the connection to the session ends under it — the session was stopped, or it -dropped a client that fell too far behind — the TUI leaves and says so, with a -non-zero status. What it had selected and scrolled is written back either way, so -reattaching returns to it. There is no automatic reconnect: reattach when the -session is up. - -Leaving the TUI (` q`) detaches: the session, and everything running in -its terminals, keeps going. Stopping the session is stopping the process you -started it in — or `kill`ing it, if you used `-d`, in which case its output is -in `~/.nightcrow/daemon.out`. Under a service manager, start it *without* `-d`: -backgrounding is what the manager does itself. - -Startup panes belong to a project, not to the process: each project you open -gets its own set. So `nightcrow --exec claude` with no repositories starts -`claude` in the first project opened, not before there is one to open it in. - -`--exec` panes open after any `[[startup_command]]` panes from the config -file; the two sources share a combined cap of 8 panes — the same count the -` 3`–`9`,`0` jump keys address, so every startup pane is reachable by -a direct key. (` 1`/`2` map to the file list and diff viewer.) Panes -opened later with ` t` are not capped; any past the eighth are reached -by focus cycling (`Shift+←/→`). - -## Projects - -One nightcrow process holds up to **10 repositories at once**, each in its own -tab across the top row. A project owns everything scoped to its repo — the git -views, the snapshot worker, and its own set of terminal panes — so switching -tabs swaps the whole screen, not just the diff. A pane running a build in one -project keeps running while you work in another. - -``` - F1 nightcrow F2 api-server +3 ← project tabs (active one accented) -┌ ^F 1 Files ──────┐┌ ^F 2 src/main.rs ────┐ -``` - -- `^F o` opens a repo in a tab, `^F x` closes the active one, and `F1`…`F10` - switch between them. There is no "change this tab's repo": closing and - opening is the same thing, and it tears the old project down properly - instead of leaving its shells behind in the previous directory. -- Opening a repo another tab already holds focuses that tab instead of running - two copies against one worktree. -- When the tabs outgrow the row, it scrolls around the active tab and folds the - rest behind `+N` markers; clicking a marker jumps to the nearest project - behind it. - -**No project open** is a normal state, not an error — it is how a fresh -session starts, and where closing the last tab returns you. The screen -keeps its chrome and offers the only two things that apply: `^F o` to open a -repo, `^F q` to quit. - -Each project keeps its own session file (see -[Session persistence](#session-persistence)), so tabs restore independently. - -## Views - -**Status view** (default) — lists changed files on the left, syntax-highlighted diff on the right. - -Each row begins with a two-character `XY` status code, following Git's short status notation (nightcrow reads status through git2 internally, not by parsing `git status --short`). `X` is the staged (index) state and `Y` is the unstaged (working-tree) state, so a file can show both at once: - -| Code | Meaning | -| --- | --- | -| ` M` | modified, unstaged | -| `M ` | modified, staged | -| `MM` | modified, staged **and** further modified in the working tree | -| `A ` | added (staged) | -| `D `/` D` | deleted (staged / unstaged) | -| `R ` | renamed (shown as `old -> new`; searchable by either path) | -| `T ` | type changed (e.g. file ↔ symlink) | -| `??` | untracked | -| `UU` | conflicted (placeholder for unmerged paths) | - -The diff for a selected file shows the combined working-tree-with-index changes. - -**Commit log view** (` l`) — tig-like commit list on the left, full commit diff on the right. Commits ahead of the upstream are marked with `↑`. Press `Enter` on a commit to drill into its individual files; `Esc` to go back. The list auto-refreshes when the workdir HEAD changes (commits made in the terminal pane, amends, force-pushes, branch switches). History loads one page at a time — initial entry fetches `commit_log_page_size` commits and additional pages stream in on a background thread as the selection approaches the loaded tail, so deep histories stay responsive. Toggling while a terminal or diff pane is zoomed exits the zoom and focuses the list, so the view switch is always visible. - -**Tree view** (` b`) — a read-only directory tree of the whole working tree on the left, with the selected file's raw contents on the right. Unlike the status view (which lists only changed files), the tree lets you browse and read *any* file next to the diff without leaving nightcrow. `j`/`k` move the cursor, `→` expands a directory (read lazily, one level at a time), `←` collapses it or steps up to the parent, and selecting a file previews it. `Enter` on a file row opens it in the preview pane and zooms that pane fullscreen (`Enter` again, or ` f`, exits the zoom); on a directory row it does nothing. Press `/` while the tree is focused for a recursive filename search across the whole tree — type to filter, `Enter` reveals the selected match in place (expanding its ancestor directories), `Esc` cancels. Focus the file preview with ` 2`, then press `/` to search within the file contents — `n`/`N` jump to the next/previous match, `Esc` clears the search. `.gitignore`-matched paths (e.g. `target/`, `node_modules/`) are hidden by default — toggle with `[tree] respect_gitignore`. Expanded directories are watched for filesystem changes, so files and folders created, moved, or deleted by another process (an editor, `git`, an LLM CLI) appear without leaving the view; set `[tree] live_watch = false` to refresh only on entry instead. The tree never writes, renames, or deletes anything. Expansion state and the selected path persist across sessions. - -**Notice row** — a one-row strip just above the hint bar shows the repo path (home-relative, e.g. `~/projects/myapp`), the current branch, and ahead/behind counts (`↑N ↓M`) when the branch tracks an upstream. When something fails — a git snapshot, a diff load, a terminal pane, or a repo path you typed that doesn't exist — the message takes over this row in red until the problem is resolved or you act on the app again. A rejected repo path therefore appears directly above the input you're correcting. The repo dialog's completion candidates share this row (dimmed, and a notice outranks them), so a list too long for one line ends in `+N more`. - -**Path completion in the repo dialog** — `Tab` completes the directory you're typing, so you don't have to know the path by heart. One press extends as far as the names allow; when there's nothing left to extend it lists what's there instead. On a trailing `/` the first press shows that directory's contents, and a unique match gains a trailing `/` so you can keep pressing `Tab` to descend. Only directories are offered (a file can't be a repo), dotted directories stay hidden until you type a leading `.`, and a name that differs only in case is matched and corrected for you. The dialog is a path field, not a shell — `~`, `..` and paths relative to your working directory all work, but `cd`, `$VAR`, and globs don't, and `Enter` always means "open this path". - -**Browsing for a repo** — when you don't know the path, press `↓` in the repo dialog to browse instead of typing. (A second `Tab`, once the candidate list is up, opens the same browser: at that point the flat list has told you all it can.) The browser fills the body of the screen, rooted at whatever directory the field currently names, and the field stays visible below it with the keys spelled out. - -| Key | Action | -|-----|--------| -| `↓` / `j`, `↑` / `k` | Move the cursor | -| `→` | Expand the selected directory (read lazily, one level at a time) | -| `←` | Collapse it, or step out — to the parent row, or one level *above the root* when you're already at the top, so a sibling checkout is one press away | -| `Enter` | Take the selected path into the field and return to it — this does **not** open the repo. Press `Enter` again in the field for that, or keep refining the path with `Tab` first | -| `Esc` | Leave the browser, keeping the text it started from. A second `Esc` cancels the dialog | - -Directories only, hidden ones excluded, and nothing is ever written. Note that `Enter` means *select* here but *open* in the field — the browser's job is to fill the field, so `→` alone expands — matching the file-tree view, where `Enter` opens a file rather than expanding. Paths keep your own notation: browsing out of `~/coding` gives you back `~/coding/…`, not an absolute path. Mouse selection isn't supported; the browser is keyboard-only. - -## Keyboard shortcuts - -nightcrow uses a tmux-style **leader (prefix)** key for its app commands. The -default leader is `Ctrl+F` (configurable via `[input] leader`). `Ctrl+F` is a -one-handed left-hand chord that avoids tmux's own `Ctrl+B` prefix (so nightcrow -stays usable inside a tmux session), terminal flow control (`Ctrl+Q`/`Ctrl+S`), -the shell signals (`Ctrl+C/D/Z`), and the Ctrl chords an inner Claude Code pane -reserves (`Ctrl+G` is its external editor, plus `Ctrl+O/R/S/T/L`) — its only -claimant is `Ctrl+F` as forward-char/page-forward, which most users reach via -the arrow keys instead. Press the leader, then a single follow-up key. Every -other key — including Ctrl chords like `Ctrl+W` and `Ctrl+L` — passes straight -through to the focused terminal, so a CLI running there (claude, codex, your -shell) receives them unchanged. This is why the leader exists: cockpit users -live inside the terminal panes and need their prompt-editing keys to reach the -program, not nightcrow. - -The hint bar shows the active leader in caret notation at its left edge (e.g. -`^F: leader` for the default `Ctrl+F`), so the configured prefix is always -visible from the terminal pane. - -> **Migration from earlier versions:** the old bare-`Ctrl` app shortcuts moved -> behind the leader. `Ctrl+T/W/L/O/P/Q` are now ` t/w/l/o/p/q` and pass -> through to the terminal program instead; `Ctrl+F` is now the leader itself -> (` f` toggles fullscreen). The old `Ctrl+Q`-twice quit confirmation is -> gone; quit with ` q`. - -### Leader commands (press ``, then the key) - -| Key | Action | -|-----|--------| -| `` then `` | Send the literal leader to the terminal program | -| ` t` | Open new terminal pane | -| ` w` | Close active terminal pane — terminal focus only, since without it no pane is highlighted as the close target | -| ` s` then `3`…`9`,`0` | Swap the active terminal pane with pane 1…8 (focus follows the pane; same pane numbering as the jump keys, so in terminal fullscreen the swap digits are `1`…`8`) — terminal focus only, like `w`, and needs at least two panes | -| ` z` | Resize the session's terminal panes to fit this screen. A PTY has one size and a program drawing on an alternate screen cannot be re-flowed afterwards, so one screen decides it for the whole session — whichever viewer opened most recently, until another asks. While someone else holds it (a second terminal, or a browser tab) this one renders that grid: padded if it is smaller than the pane, cropped if larger. Advertised in the hint bar only while that is the case | -| ` c` | Give up on the recovery a plugin has pending for a pane — the held slot is released, so nothing can be relaunched into it, and every attached client is told. Targets the focused pane's recovery, or the pane whose process has already ended while its slot was being held (that pane has no tab to focus). Advertised in the hint bar only while something is actually pending | -| ` l` | Toggle between status view and commit log view | -| ` b` | Toggle the read-only file-tree view (returns to status view) | -| ` f` | Fullscreen the focused pane. For the terminal it cycles `off → grid (all panes) → zoom (active pane only) → off`; with a single pane it toggles straight off/on. File list and diff viewer toggle off/on | -| ` o` | Open a repo in a **project tab** (prefilled with the active project's path — type to replace it, or press `→`/`End` first to extend it). `Tab` completes the path against your filesystem and `↓` opens a directory browser (see below). A leading `~` expands to your home directory. If another tab already has that repo open, nightcrow focuses that tab instead of running two copies against one worktree | -| ` x` | Close the active project tab. Closing the last one leaves nightcrow with no project open, which is a normal state | -| ` p` | Cycle accent color (yellow → cyan → green → magenta → blue). The accent belongs to the session, so every attached TUI and every open browser follows | -| ` u` | Re-read `config.toml` without restarting the session. `[[plugin]]` is re-applied to every open project immediately; `[[startup_command]]` applies to projects you open afterwards, because the panes an open project already started are live processes. Everything else in the file still needs a restart. The result appears on the notice row — see [Reloading the config](#reloading-the-config) | -| ` r` | Force a full redraw (clears stray glyphs left by terminal programs) | -| ` q` | Quit | -| ` 1` / ` 2` | Focus the file/commit list / diff viewer — **split view only** | -| ` 3`…` 9`, ` 0` | Jump to terminal pane 1…8 (`0` addresses pane 8) | -| ` 1`…` 8` (terminal fullscreen) | Jump to terminal pane 1…8. With the viewer hidden the digit row addresses panes by natural numbering; `9`/`0` are unused. The only way back to the list/diff is ` f` to leave fullscreen | -| `Esc` / `Ctrl+C` (while armed) | Cancel the prefix | - -The prefix has no timeout: once armed it waits indefinitely for the follow-up -key. A key with no leader binding cancels the prefix and is dropped. - -` s` is the one two-step chord: it arms a swap mode (shown as `SWAP` in -the hint bar) that waits for a pane digit, then swaps the active pane with the -chosen one. A non-digit follow-up or `Esc` cancels swap mode without reordering. - -### Global (no prefix) - -| Key | Action | -|-----|--------| -| `Shift+→` / `Shift+←` | Cycle focus: file list → diff viewer → terminal panes → … | -| `F1`…`F10` | Switch to project tab 1…10 — see [Projects](#projects). Unlike the pane digits, this mapping does not change with the layout: the same F-key reaches the same project in every view, fullscreen included | - -A modified F-key (`Ctrl+F1`, `Shift+F5`, …) is not intercepted and passes -through to the terminal program. - -### File list / Commit list (left panel) - -| Key | Action | -|-----|--------| -| `↑` / `k`, `↓` / `j` | Navigate items one by one | -| `PgUp` / `PgDn` | Jump 10 items | -| `←` / `→` | Scroll long paths and commit summaries horizontally (in tree view these expand/collapse instead) | -| ` f` | Zoom the list pane to full screen (toggle) | -| `/` | Incremental search (status: paths; log: commit summaries; drill-down: paths; tree: recursive filenames) | -| `Esc` | Clear filter, then exit drill-down (log), then cancel search bar | -| `Enter` | Confirm filter (keeps query), drill into commit's file list (log view), or open the selected file fullscreen (tree view) | - -### Diff viewer (right panel) - -| Key | Action | -|-----|--------| -| `↑` / `k`, `↓` / `j` | Scroll one line | -| `PgUp` / `PgDn` | Scroll 20 lines | -| `←` / `→` | Horizontal scroll (4 columns) | -| `v` | Toggle between hunk diff and full file preview | -| `w` | Toggle soft wrapping of long lines. On, the tail of a long line continues on the next row instead of needing `←`/`→`; the line number folds into the line rather than sitting in its own column, so a continuation row carries no number. Horizontal scrolling is inert while wrapping (and the offset resets when you turn it on). The split view ignores wrapping — halves folding to different heights would stop lining up | -| `Tab` | Cycle the display: unified diff → side-by-side split → file contents → unified. `v` and `s` each toggle one view against the unified default, so the third stays hidden unless you know it exists; `Tab` walks all three. Skips the file step when there is no file to open, and does nothing in tree view | -| `s` | Toggle between the unified diff and a side-by-side split view (falls back to unified when the pane is too narrow) | -| — | **Line numbers** are always shown in a pinned gutter. The unified view shows both sides (old, new) — an added line leaves the old column blank, a removed line leaves the new one blank. The split view numbers each half with the side it shows, and the file view (`v`) numbers the file itself. The gutter stays in place while `←`/`→` scroll the code | -| ` f` | Zoom the diff/file pane to full screen (toggle) | -| `Enter` | Zoom the diff/file pane to full screen (toggle) — same as ` f` | -| `/` | Open search (works in both diff and file preview, including tree mode) | -| `n` / `N` | Next / previous search match | -| `Esc` | Clear search | - -### Terminal panes (bottom) - -Every visible pane renders at once as a split grid instead of switching -between tabs — 2 panes go side by side (or stacked if the terminal is -narrow), 4 form a 2x2 grid, up to 4 show normally and up to 8 in the -fullscreen grid. ` f` cycles the terminal through `off → grid → -zoom → off`: *grid* hides the top viewer and fills the screen with the -split grid, *zoom* fills the screen with just the active pane. The active -pane's cell is bordered in the accent color; jumping focus with ` 3`–`9`,`0` -or `Shift+←/→` moves that border (and, while zoomed, the pane on screen) -without closing any other pane. With more panes than fit, the tab bar -shows a `+N` marker for the ones scrolled out of view — they keep running -in the background. Keyboard input, paste, and scroll still target only the -active pane. A single pane draws with no cell border, exactly as before -split view existed. - -| Key | Action | -|-----|--------| -| `Shift+↑` / `Shift+↓` | Scroll terminal output 3 lines | -| `Shift+PgUp` / `Shift+PgDn` | Scroll terminal output one page | - -While scrolled, the terminal border title shows `[SCROLL — shift+pgdn: down | input: live]`. Keyboard input is still forwarded to the running process; `Shift+PgDn` to scroll back to the bottom. - -The tab bar picks up OSC 0/2 window-title escape sequences, so programs like `claude`, `vim`, `ssh`, or `cd`-aware shell prompts can rename their own tab. Panes without an emitted title fall back to a default label. - -### Mouse - -nightcrow captures the mouse by default (`[mouse]` in the configuration): - -- **Click a pane** to focus it, same as a jump key. The click is also forwarded to programs that asked for mouse reports (Claude Code, `less --mouse`, …) — so their clickable UI, like Claude Code's jump-to-bottom control, works. A plain shell receives nothing. -- **Click the file list or diff viewer** to focus that panel, same as ` 1`/`2`. -- **Click a project tab** in the top row to switch to it, same as its `F`-key. A `+N` overflow marker jumps to the nearest project folded behind it. -- **Wheel** scrolls the pane under the pointer, routed exactly like the scroll keys (wheel reports, arrow keys, or scrollback — whatever the program expects). -- **Click a tab** in the terminal tab bar to jump to that pane; clicking a `+N` hidden-pane marker reveals the nearest hidden pane on that side. -- **Click `o: open project`** on the empty screen — with no project open it is the one action the hint bar offers, and it dispatches like its key. -- **Click a shortcut** in the bottom hint bar to run it — command hints like `t: new pane`, `w: close pane`, or `f: fullscreen` dispatch exactly as if you pressed the keys they name. Clickable hints render inverted (reverse video) across their whole label so they stand out from informational hints; the inversion disappears when `[mouse]` is disabled. Navigation hints and `q: quit` are not clickable (quitting stays a deliberate two-key act). -- **Select text with a bypass modifier + drag.** While the mouse is captured, the outer terminal performs its native selection and copy only when you hold its bypass modifier while dragging. The modifier depends on the terminal: **Shift** in xterm-family terminals (Alacritty, kitty, GNOME Terminal, Windows Terminal), **Option (⌥)** in iTerm2, **Fn or Option** in macOS Terminal.app. Set `enabled = false` under `[mouse]` to give the mouse back to the outer terminal entirely — plain-drag selection returns, click forwarding stops. - -## Recent-activity focus indicator - -Files modified within the last `hot_window_secs` seconds — whether by an agent in a terminal pane, your editor, or a build/format script — are rendered in the accent color (bold for the first 5 seconds, normal until the window expires). When the file list is in focus and you have not navigated in the last 2 seconds, the selection auto-follows to the freshest hot file so the diff updates as files change. Manual navigation (`j` / `k` / arrows / PgUp / PgDn) immediately suppresses auto-follow until you go idle again. - -Configurable under `[agent_indicator]` (see below). - -## Session persistence - -nightcrow saves the current state on exit and restores it on the next launch — focus position, selected file, scroll offset, active terminal pane, view mode (status / commit log / tree), fullscreen states, commit-log drill-down position, and tree expansion and selection. - -The accent is not in that list. It belongs to the session rather than to one repo's view state, so it lives in `~/.nightcrow/viewer.json` alongside the viewer's other shared preferences and is not restored per repo. - -The browser keeps its own half of this. Which panel each project is maximized in is remembered per project in `viewer.json`, so a refresh comes back to the layout you left — the browser's counterpart to the fullscreen states above, kept apart from them because maximizing on a 40-row terminal and in a browser window are not the same answer. It is held for 50 projects, like the TUI's — the 50 whose arrangement was set most recently, so maximizing a fifty-first is what drops the oldest, not merely opening one. - -Everything else lands in one file, `~/.nightcrow/workspace.json` — which repos were open, which tab was in front, and each repo's view state. Nothing is written inside your repositories: no single repo owns the fact that others were open beside it, and nightcrow shouldn't create directories in a project it is only reading. - -A bare `nightcrow` reopens those tabs and lands on the one that was in front, with each project's selection and scroll where you left them. Repos that have moved or been deleted since are skipped, with a notice saying how many. View state is kept for the 50 most recently used repos. - -The two halves have two owners. The session writes which repositories are open and which tab is in front; an attached client writes what it had selected and scrolled, and never the tab list — detaching must not roll the session back to one client's view of it. To start empty, close every tab before stopping the session. - -## Plugins - -nightcrow itself knows nothing about the CLIs you run in its panes — an agent -and a person get the same PTY. Behaviour that *does* need to know a particular -tool lives in a plugin: a separate executable that nightcrow launches and talks -to over a pipe. Plugins are off unless you turn one on, and one only ever sees a -pane you handed it by name — or, if you also set `watch_on_signal`, a pane that -something running inside it spoke to the plugin from. A plugin is never given a -list of your panes either way. - -The bundled plugin is `nightcrow-recovery`. When a watched pane's CLI hits its -usage limit, it waits for the reset time the provider reported and then re-opens -that exact session. It only waits — it does not bypass, raise, or work around -any provider limit, and it sends nothing while a limit is in effect. Claude -Code, Codex CLI, and OpenCode are supported; OpenCode is only ever observed, -never interrupted, because it retries on its own. - -```bash -cargo build --release -p nightcrow-recovery -nightcrow plugin install target/release/nightcrow-recovery --name recovery -nightcrow plugin list # what is installed, and how config refers to it -nightcrow plugin remove recovery -``` - -`install` prints the exact `[[plugin]]` block to paste, using whatever `--name` -you chose — that name is what a pane opts in with, so keep the two in step. - -Installing only puts the binary in `~/.nightcrow/plugins`. It stays inert until -you edit `~/.nightcrow/config.toml` yourself — enabling something that can type -into a terminal should be a change you read before it takes effect: - -```toml -[[plugin]] -name = "recovery" -command = "nightcrow-recovery" -enabled = true -# Flags the plugin may append to re-open a session. Empty by default, which -# refuses every relaunch. nightcrow cannot know what a CLI's flags mean, so it -# will not add one you did not list — that is what keeps a plugin from changing -# how a CLI asks for your approval. -allowed_resume_flags = ["--resume", "resume", "--session"] - -[[startup_command]] -name = "Claude" -command = "claude" -plugin = "recovery" # without this line, no plugin sees this pane unless - # watch_on_signal is set (see below) -``` - -That covers the panes you configured. For the pane you did not — you opened a -shell with ` t` and typed `claude` into it yourself — add -`watch_on_signal = true` to the `[[plugin]]` block. nightcrow puts a random token -in each pane's environment and nowhere else, so the CLI's own hook can quote it -back and the plugin can ask for "the pane this token names"; a plain shell never -speaks to a plugin, so your shells stay untouched. It is off by default. Such a -pane can be waited for and typed into but never relaunched — nightcrow launched -no command in it, so there is nothing to put back. - -For Claude Code, let the plugin install its hook and statusline entries so it -can read the exact session id and reset time instead of guessing from what is -printed on screen. With a reset time it waits exactly once; without one it falls -back to retrying on a backoff, which can give up. It merges into your existing -`~/.claude/settings.json` and backs it up first: - -```bash -nightcrow-recovery install-hooks -nightcrow-recovery uninstall-hooks # removes only what it added -``` - -Claude Code's `statusLine` holds one command, so installing does replace yours — -but it is then run from the plugin's own statusline with the same input, and what -it prints is what you see. `uninstall-hooks` puts it back. - -A pane that is waiting shows its state and deadline on its tab. Cancel it with -`` then the recovery key (see [Leader commands](#leader-commands-press-prefix-then-the-key)), -or from the web viewer; typing into the pane yourself also cancels it. - -Design and trust boundary: [`docs/architecture.md`](docs/architecture.md) → "Plugin Host". - -## Web viewer - -A browser surface that renders the same git data as a native web page — -selectable text, real scrolling, clickable paths, and a layout that adapts to a -phone (see below). It also serves its **own** terminals, independent of the -TUI's panes. - -The served repositories appear as project tabs in the header — `+ open` browses -the server machine's folders to add one, `×` closes it, and dragging a tab -reorders them. The same dialog **clones a git URL** into the folder it is -showing: paste `https://…` or `git@host:path`, and the repository opens as a -tab when the clone finishes. Cloning runs `git` on the server, so it uses that -machine's credentials — an SSH agent, a credential helper — and a private -remote works exactly as it would in a shell there. Local paths and git's -`ext::` transport are refused. A clone keeps running whether or not you stay to -watch it: closing the dialog leaves `Cloning…` in the header, and a page you -reload — or a phone that dropped the tab mid-transfer — picks the same clone -back up and still opens the repository when it lands. Each project has its own `status`, `log`, and `tree` tabs on -the left plus a terminal panel below. The order is kept on the server, so every -device shows the same arrangement, and it survives a -restart (alongside the TUI it lasts the session). On a narrow window (phone) the -tab row folds into a dropdown showing the current project. - -In the `log` tab, selecting a commit opens its changed-file list alongside the -complete commit diff. Select a file to view only that file's change; use -`< log` to return or `all changes` to restore the complete commit diff. - -History loads a page at a time, as the TUI's does — scrolling toward the end of -the list fetches the next page, so deep histories stay reachable without loading -them up front. The filter narrows the commits already loaded rather than -searching the server, so paging pauses while a query is up — the list says how -many are loaded, and clearing the filter resumes loading. The list is the history as of entering the tab: unlike -the TUI it does not follow HEAD, so a commit made in the terminal panel appears -after leaving and re-entering the tab. - -The swatch in the header cycles the accent colour through the same five -presets as the TUI's ` p` (yellow → cyan → green → magenta → blue) — -and it is the same colour, not a parallel one. The choice is stored on the -server (`~/.nightcrow/viewer.json`), so every device that opens the viewer and -every attached TUI shows it, and a change made anywhere reaches the browsers -within a few seconds and attached terminals immediately. `[theme] name` sets -the colour a session starts with, before anyone has picked one. - -Drag the divider between the file list and the diff pane to resize the sidebar, -or double-click it to reset the default width. The width is stored on the server -the same way as the accent, so every device opens at the same split; it is -bounded so the diff pane always keeps at least half the window. - -The border between the diff panel and the terminal panel is a divider too: drag -it to give the terminal more or less of the window, double-click to go back to -the default 55/45. It is stored on the server like the sidebar width, so every -browser opens at the same split, and bounded so neither panel shrinks to a -sliver — for "all the way" use the maximize buttons on either panel. Unlike the -accent, this one is **not** shared with an attached TUI: the TUI keeps its own -`[layout] upper_pct`, because the same percentage means a different number of -rows on a terminal than in a browser window, and the terminals' actual size is -already decided by whichever client owns the sizing. - -The diff pane has a toggle (top-right of the pane) that switches between the -inline unified diff and a side-by-side split view, mirroring the TUI's `s`. -The choice lasts the page, the same lifetime the TUI gives it; on a narrow -window (phone) the two sides stack — removed above added — rather than sitting -side by side, since neither column would have the width to read. - -**Line numbers** ride in a pinned gutter as they do in the TUI: the unified -view shows both sides (old, new), leaving a column blank where the line does -not exist on that side; each split half shows the side it renders; and a file -opened from the tree is numbered by its own lines. The gutter stays put while -the code scrolls sideways, and the numbers stay out of anything you copy. - -Each terminal pane's toolbar has a **fit to this screen** button, the browser's -half of the TUI's ` z`. It is offered only while another screen holds -the sizing, because a PTY has one size for the whole session: the panes are -fitted to whichever viewer opened most recently, and everyone else renders that -grid until someone asks for it. Switching projects does not move it, and neither -does a dropped connection coming back — a tab is one screen however many sockets -it opens. Reloading the page counts as opening it, so it takes the sizing again, -as a new tab would. - -Drag a terminal pane by its header onto another to reorder the split-view grid; -it works with touch as well as a mouse. The order is kept on the server, so a -refresh, a reconnect, or another device opening the same repository all show the -same arrangement. (It is not written to disk — a server restart clears the -terminals themselves, so there is nothing to persist.) - -**On a phone**, the three regions the desktop shows at once — the file/commit -list, the content pane, and the terminal — would each shrink to an unusable -sliver stacked in one column, so instead a bottom bar switches between them: -tap **Files**, **Diff**, or **Terminal** to give one of them the whole screen. -Opening a file or commit jumps to the content view automatically. Because a -soft keyboard can't type Escape, Tab, Shift-Tab, Ctrl combinations, or the -arrows, the terminal grows a key bar along its bottom on touch devices that -sends those straight to the shell — so you can interrupt a process (`^C`), -leave `vim` (`Esc`), cycle a completion menu backwards (`⇧Tab`), or walk your -history (arrows) without a physical keyboard. - -The viewer ships a web-app manifest and icons, so you can **add it to your home -screen** and launch it as a standalone, chrome-less window — more room for the -terminal and one-tap access. On iOS this works over plain HTTP (Safari → -*Share* → *Add to Home Screen*). Android's install prompt additionally wants a -service worker and a secure origin, so reach the viewer over HTTPS (a reverse -proxy or tunnel) to get it there; the viewer has no offline mode either way — -every screen needs the server. - -The `status` list highlights recently touched files the same way the TUI does: -accent-coloured and bold for the first 5 seconds after a file's mtime, accent -until `agent_indicator.hot_window_secs` expires, then plain. The window (and -whether the highlight runs at all) comes from the server's `[agent_indicator]` -settings, so both surfaces fade on the same schedule. Ageing is measured -against the browser's clock, so a device whose time is badly off will fade -early or late. - -Markdown files (`.md`, `.markdown`) opened from the tree render as formatted -documents by default, with fenced code syntax-highlighted. HTML files -(`.html`, `.htm`) render too, inside a fully sandboxed frame: scripts do not -run, and nothing loads from another host. A page that carries its own styling -inline and embeds images as `data:` URIs shows in full; one that links a -stylesheet or images as separate files shows without them, since repository -files are not served to the frame. This previews a self-contained page rather -than a site. A toggle (top-right of the -pane) switches either back to the raw highlighted source. - -It is always on — it is one of the session's two faces, not an add-on. Configure -where it listens under `[web_viewer]`: - -```toml -[web_viewer] -bind = "127.0.0.1" # loopback only; change deliberately -port = 8091 -# password = "..." # auto-generated and written here on first launch if unset ``` -`--port` and `--bind` override those for one run: - -```bash -nightcrow --port 9000 -``` - -Repositories opened or closed in the browser reach every attached terminal, and -are written back to `~/.nightcrow/workspace.json` so the next session starts on -the same set. - -**Authentication.** If no `password` is set when the viewer is enabled, a random -one is generated and written back into your config (so it survives restarts and -stays readable) and printed once at startup. To avoid a plaintext password on -disk, set `hashed_password` to an Argon2 PHC string instead — it takes -precedence. Login is rate-limited and grants a session cookie. - -> **Security.** The viewer serves repository contents *and* interactive +The session prints the address of its browser view (`http://127.0.0.1:8091/` by +default) and the socket an attaching terminal uses. Both show the same +repositories — open one with ` o` in the TUI or the folder picker in the +browser, and it appears in the other. + +The leader (prefix) key is `Ctrl+F` by default. Press it, then one key: +`o` opens a repo, `t` a terminal pane, `l` the commit log, `b` the file tree, +`f` fullscreen, `q` quits. Every other key — including Ctrl chords — passes +straight through to the focused terminal, so the CLI running there receives them +unchanged. + +## What it does + +- **Up to 10 repositories at once**, each a project tab with its own git views, + snapshot worker, and terminal panes. → [Projects](docs/projects.md) +- **Three views** over each repo — changed files with a syntax-highlighted diff, + a tig-like commit log, and a read-only file tree you can browse and search. + → [Views](docs/views.md) +- **A split-grid terminal panel** where every visible pane renders at once, with + scrollback, OSC title capture, and mouse routing. → + [Keyboard and mouse](docs/keybindings.md) +- **A browser surface** serving the same git data and the same terminals, with a + phone layout and an on-screen key bar. → [Web viewer](docs/web-viewer.md) +- **Recent-activity highlighting** — files touched in the last few seconds are + accented, so you see what an agent just changed. + → [Session state](docs/session-state.md) +- **Session persistence** — tabs, selection, scroll, and view mode come back on + the next launch. Nothing is written inside your repositories. +- **Plugins** for behaviour that must know a specific CLI; the bundled + `nightcrow-recovery` waits out a provider's usage limit and re-opens the + session. → [Plugins](docs/plugins.md) + +Configure it in `~/.nightcrow/config.toml` (`nightcrow init` writes a commented +starter) — see [Configuration](docs/configuration.md). + +> **Security.** The web viewer serves repository contents *and* interactive > terminals, so an authenticated session is equivalent to shell access. It binds -> to loopback (`127.0.0.1`) by default and speaks plain HTTP with **no built-in -> TLS**. For remote access, do **not** expose the port directly — tunnel it over -> SSH (`ssh -L 8091:127.0.0.1:8091 host`) or put it behind a TLS reverse proxy. - -### Developing the frontend - -The UI lives in `viewer-ui/` (React + Vite + Tailwind). Its build output is -committed to `viewer-ui/dist/` and embedded into the binary, so installing -nightcrow never requires Node. - -```bash -npm --prefix viewer-ui install -npm --prefix viewer-ui run dev # Vite on :5173, proxying the API to :8091 -npm --prefix viewer-ui run build # rebuild dist/ — commit the result -``` - -CI rebuilds the bundle and fails if it differs from what is committed. - -## Configuration - -Config file: `~/.nightcrow/config.toml` (all fields optional, defaults shown). -nightcrow runs on built-in defaults when the file is absent and never creates -it on its own. To get a starter file, run: - -```bash -nightcrow init # writes a commented ~/.nightcrow/config.toml -nightcrow init --force # overwrite an existing file -``` - -`init` leaves an existing config untouched unless `--force` is passed. - -```toml -[layout] -upper_pct = 55 # vertical % for the diff panel (1–99) — the TUI's own; the - # viewer keeps a separate dragged value in viewer.json -file_list_pct = 25 # horizontal % of upper panel for the file list (1–99) - -[theme] -name = "yellow" # accent a session starts with, before anyone picks one: - # "yellow" | "cyan" | "green" | "magenta" | "blue" - -[input] -leader = "ctrl+f" # leader (prefix) chord for app commands; tmux-style. - # Allowed: "ctrl+". Reserved keys (F1..F10, - # Shift+arrows, Shift+PgUp/PgDn) cannot be the leader. - -[mouse] -enabled = true # capture the mouse: click to focus/forward, wheel scrolls - # the pane under the pointer; select text with the - # terminal's bypass modifier + drag (Shift in xterm-family, - # Option in iTerm2, Fn/Option in macOS Terminal.app). - # false = plain-drag selection, no click forwarding. - -[web_viewer] -bind = "127.0.0.1" # loopback only by default; plain HTTP, so tunnel/proxy for remote -port = 8091 -# password = "..." # auto-generated + saved here on first launch if unset -# hashed_password = "..." # Argon2 PHC string; takes precedence over `password` - -[log] -enabled = true -dir = ".nightcrow/logs" # relative paths resolve under the home directory -rotation = "daily" # "daily" | "hourly" | "size" -max_size_mb = 10 # used when rotation = "size" -max_days = 7 # delete logs older than N days (0 = keep forever) -level = "info" # "error" | "warn" | "info" | "debug" | "trace" -prompt_log = false # record terminal prompt input line by line -commit_log_page_size = 100 # commits fetched per commit-log page -commit_log_prefetch_threshold = 25 # start the next-page fetch when the selection is within - # this many rows of the loaded tail (1..=page_size) - -[agent_indicator] -enabled = true # color recently-touched files in the file list -hot_window_secs = 15 # seconds within which a file stays hot (3–3600) -auto_follow = false # jump selection to the freshest hot file when idle - -# Read-only directory-tree navigator (enter with b). -[tree] -respect_gitignore = true # hide .gitignore-matched paths (target/, node_modules/, …) -max_depth = 64 # deepest directory level the tree will expand into (1..=1024) -live_watch = true # watch expanded dirs and refresh the tree live; set false - # to refresh only on tree entry (large trees / odd filesystems) - -# Reserve startup commands: each [[startup_command]] opens its own terminal -# pane at launch and runs `command` immediately (via `$SHELL -lc `). -# Up to 8 entries (combined with CLI --exec). 8 matches the 3–9,0 -# jump keys, so every startup pane is reachable by a direct key ( 1/2 -# reach the file list and diff viewer). This caps only the startup batch — open -# more anytime with t (panes past the eighth are reached by focus -# cycling, Shift+←/→). `name` labels the tab; when omitted the command text is -# used. With no [[startup_command]] entries, nightcrow opens a single empty shell. -[[startup_command]] -name = "Claude" # optional tab label; falls back to the command text -command = "claude" # required; must not be empty -plugin = "recovery" # optional; names the [[plugin]] allowed to act on this - # pane. Omitted — the default — means no plugin sees - # it unless that plugin sets watch_on_signal. - -[[startup_command]] -command = "cargo test --watch" - -# External plugin processes — see "Plugins" above. Up to 8 entries, names unique. -# Nothing runs unless an entry exists AND enabled = true AND either a pane opted -# in or watch_on_signal is set. -[[plugin]] -name = "recovery" # the name panes opt in with -command = "nightcrow-recovery" # found on PATH or in ~/.nightcrow/plugins -args = [] # passed to the plugin verbatim -enabled = false # off by default -watch_on_signal = false # off by default; when true, a pane no - # [[startup_command]] named is also handed over - # once something inside it quotes that pane's - # token to this plugin. Such a pane is never - # relaunched, only typed into while it lives. -allowed_resume_flags = [] # flags the plugin may append to re-open a - # session; empty refuses every relaunch - -[plugin.env] # plugin process only, never terminal panes -NIGHTCROW_RECOVERY_LOG = "info" -``` - -### Reloading the config - -Editing `config.toml` normally means restarting the session — which kills every -pane, including whatever an agent CLI was in the middle of. Two of the tables -can be re-read instead, without stopping anything: - -- **In the TUI**: ` u`. The result appears on the notice row. -- **In the browser**: the ⟳ button in the header, next to sign out. It reloads - the *config*, not the page — nothing on screen changes, and the result comes - back as a toast. - -What a reload applies: - -| Table | When it takes effect | -| --- | --- | -| `[[plugin]]` | **Immediately, in every open project.** Newly enabled plugins start and are handed the panes that opted into them; disabled or removed ones stop and their panes carry on unwatched. A plugin whose `command`, `args` or `env` changed gets a new process; changing only `allowed_resume_flags` or `watch_on_signal` leaves the running one alone, so a plugin part-way through a long wait is not disturbed. A replacement that will not start (a command that is not there) leaves its panes unwatched too, exactly as removing it would | -| `[[startup_command]]` | **On the next project you open.** A project that is already open keeps the panes it started with — those are live processes, and no file edit replaces them | -| Everything else | Needs a restart: `[web_viewer]` (the listener is already bound), `[log]`, and the client-owned `[layout]`, `[input]`, `[tree]`, `[mouse]` sections, which each TUI reads when it attaches | +> to loopback and speaks plain HTTP with no built-in TLS. For remote access, +> tunnel it over SSH or put it behind a TLS reverse proxy. -Notes: +## Documentation -- **Nothing half-applies.** The whole file is parsed and validated first, so a - typo anywhere leaves the session exactly as it was, and the message names the - key that was wrong. -- **A missing file is refused** rather than read as "nothing is configured" — - otherwise deleting the file and reloading would be a quiet way to stop every - plugin. -- Panes opened with `--exec` are kept: they are not in the file, so a reload - merges them back where a restart would have put them. -- Disabling a plugin and enabling it again lands where enabling it the first - time would — the pane's opt-in survives, so `enabled` means the same thing - whichever way it was last flipped. -- **Restarting a plugin discards whatever it was in the middle of.** A plugin's - state lives in its process, so replacing that process loses it — for - `nightcrow-recovery` a pane parked on a quota reset hours away simply stops - being watched, and nothing will resume it. The plugin logs how many panes it - abandoned on the way out. This only happens when you change *that plugin's* - own `command`, `args` or `env`; every other edit — a new plugin, a startup - command, another plugin's flags — leaves a waiting one running. -- A pane whose process had already exited and whose slot was being held for a - relaunch gives that slot up when its plugin is stopped or replaced. The - successor is never handed the pane's token, so nothing could honour the hold; - the countdown ends instead of running out its window. -- If the result says **`(1 was too busy to be told)`**, that project kept the - plugins it had. Its terminals were too far behind to take the request, and - waiting on one project would have held up every other. Nothing else about the - reload is affected — reload again once it has caught up. The server log names - the project. +Full docs are in [`docs/`](docs/README.md) — usage guides per surface, the +[architecture](docs/architecture.md), and the +[design-decision history](docs/decisions.md). ## License diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 00000000..a62fad4b --- /dev/null +++ b/docs/README.md @@ -0,0 +1,32 @@ +# nightcrow documentation + +The [top-level README](../README.md) is the tour: what nightcrow is, how to +install it, and enough usage to get a session up. Everything past that lives +here, one page per surface. + +## Using nightcrow + +| Page | What it covers | +| --- | --- | +| [Getting started](getting-started.md) | Install, starting and stopping a session, attaching, startup panes | +| [Projects](projects.md) | Repository tabs, the empty state, per-project scope | +| [Views](views.md) | Status, commit log, and tree views; the notice row; the repo dialog and its directory browser | +| [Keyboard and mouse](keybindings.md) | The leader key, every binding, mouse routing | +| [Session state](session-state.md) | Recent-activity highlighting, what persists across restarts and who owns it | +| [Web viewer](web-viewer.md) | The browser surface, phone layout, authentication, frontend development | +| [Plugins](plugins.md) | The plugin boundary and the bundled `nightcrow-recovery` | +| [Configuration](configuration.md) | Every `config.toml` table, and which ones reload without a restart | + +## Working on nightcrow + +| Page | What it covers | +| --- | --- | +| [Architecture](architecture.md) | Index: overview, layout, module map, stack — and links into the detail pages below | +| [· Session](architecture/session.md) | Daemon ↔ client split, `TerminalBackend`, PTY size ownership, config reload | +| [· Git views](architecture/git-views.md) | Diff pipeline, gutter and wrapping, tree navigator, commit-log decoration | +| [· Terminal](architecture/terminal.md) | Split-view pane grid, emulation layer, scroll and mouse routing | +| [· UI](architecture/ui.md) | Keyboard routing, the `Workspace`/`App` project boundary, notice row | +| [· Plugin host](architecture/plugin-host.md) | The trust boundary and the recovery surface | +| [· Web layer](architecture/web.md) | Shared HTTP/SSE primitives, the viewer, the frontend | +| [Design decisions](decisions.md) | Why it went this way — rejected alternatives and where implementation diverged from plan | +| [AGENTS.md](../AGENTS.md) | Contribution workflow and repository conventions | diff --git a/docs/architecture.md b/docs/architecture.md index 8022aede..d98a9e29 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,22 +1,31 @@ # nightcrow Architecture +이 문서는 색인이다. 전체 그림과 불변식만 담고, 각 영역의 상세 설계는 `docs/architecture/` 아래 +하위 문서로 나뉘어 있다 — 맨 아래 [Detailed design](#detailed-design) 표를 보라. + ## Overview nightcrow는 **세션 데몬 하나 + 프론트엔드 N개** 구조의 agent-adjacent Rust 애플리케이션이다. -`nightcrow`가 세션(저장소 집합과 터미널)을 소유하고, 터미널에서 `nightcrow attach`로, -브라우저에서 웹으로 같은 세션에 붙는다. 클라이언트가 나가도 세션은 산다. -화면은 상단 패널에서 git diff를 실시간 추적하고, 하단 패널에서 임의의 프로세스(주로 LLM CLI나 빌드/테스트 러너)를 동시에 실행한다. -전환 과정과 남은 단계는 `docs/session-daemon-plan.md`를 참고한다. -nightcrow 자체는 AI에 대한 ontology를 갖지 않는다 — agent든 사람이든 동일한 PTY와 파일 mtime을 본다. -provider를 아는 동작(예: rate limit이 풀릴 때까지 기다렸다 세션을 재개하는 것)이 필요하면 코어가 아니라 -**plugin**이 갖는다. 코어는 pane을 외부 프로세스에 보여주고 그 프로세스가 요청한 것을 검증할 뿐, -어떤 CLI가 무엇을 출력하는지는 끝까지 모른다 — `### Plugin Host` 참고. +`nightcrow`가 세션(저장소 집합과 터미널)을 소유하고, 터미널에서 `nightcrow attach`로, 브라우저에서 +웹으로 같은 세션에 붙는다. 클라이언트가 나가도 세션은 산다. 화면은 상단 패널에서 git diff를 +실시간 추적하고, 하단 패널에서 임의의 프로세스(주로 LLM CLI나 빌드/테스트 러너)를 동시에 실행한다. + +nightcrow 자체는 AI에 대한 ontology를 갖지 않는다 — agent든 사람이든 동일한 PTY와 파일 mtime을 +본다. provider를 아는 동작(예: rate limit이 풀릴 때까지 기다렸다 세션을 재개하는 것)이 필요하면 +코어가 아니라 **plugin**이 갖는다. 코어는 pane을 외부 프로세스에 보여주고 그 프로세스가 요청한 +것을 검증할 뿐, 어떤 CLI가 무엇을 출력하는지는 끝까지 모른다 — +[plugin-host.md](architecture/plugin-host.md) 참고. -**대상 사용자**: 터미널 중심으로 작업하면서, 옆 패널의 LLM CLI(Claude Code, Codex, aider 등)나 빌드/테스트 러너가 만든 코드 변경을 실시간으로 따라잡고 싶은 개발자. +**대상 사용자**: 터미널 중심으로 작업하면서, 옆 패널의 LLM CLI(Claude Code, Codex, aider 등)나 +빌드/테스트 러너가 만든 코드 변경을 실시간으로 따라잡고 싶은 개발자. -**핵심 기능**: 멀티 프로젝트 탭(최대 10개 저장소, 프로젝트별 git 뷰 + 터미널 pane), 변경 파일 리스트(좌측/키보드 네비게이션), git diff 뷰어(우측/문법 하이라이팅), commit log 뷰, read-only 파일 트리 내비게이터(라이브 워치 + 재귀 파일명 검색 + 마크다운·HTML 렌더 뷰), split-view 멀티 PTY 패널(하단), mtime 기반 hot-file 강조 + idle auto-follow, OSC 0/2 탭 타이틀 캡처, 마우스 캡처(클릭 포커스/포워딩, 휠 라우팅, 클릭 가능한 힌트 바). +**핵심 기능**: 멀티 프로젝트 탭(최대 10개 저장소), 변경 파일 리스트 + git diff 뷰어(문법 +하이라이팅), commit log 뷰, read-only 파일 트리 내비게이터(라이브 워치 + 재귀 파일명 검색 + +마크다운·HTML 렌더 뷰), split-view 멀티 PTY 패널, mtime 기반 hot-file 강조 + idle auto-follow, +OSC 0/2 탭 타이틀 캡처, 마우스 캡처(클릭 포커스/포워딩, 휠 라우팅, 클릭 가능한 힌트 바). -**웹 표면**: 같은 git 데이터를 DOM으로 렌더하고 세션의 터미널을 서빙하는 웹 뷰어(`[web_viewer]`). 세션의 일부라 항상 뜨며, attach 소켓과 인증 방식이 다르다 — 소켓은 파일 권한, 웹은 Argon2 로그인. +**웹 표면**: 같은 git 데이터를 DOM으로 렌더하고 세션의 터미널을 서빙하는 웹 뷰어(`[web_viewer]`). +세션의 일부라 항상 뜨며, attach 소켓과 인증 방식이 다르다 — 소켓은 파일 권한, 웹은 Argon2 로그인. ## Layout @@ -36,1226 +45,192 @@ provider를 아는 동작(예: rate limit이 풀릴 때까지 기다렸다 세 └─────────────────────────────────────────────┘ ``` -프로젝트 탭 행은 상단, 나머지 크롬 두 행(notice row + hint bar)은 하단에 -모여 있다. 네 행 분할은 `ui::mod::chrome_rows` 한 곳에서만 계산된다 — -`draw`와 세 개의 geometry helper(PTY 사이저, upper-panel/hint-bar hit -test)가 정확히 같은 셀에 떨어져야 하므로, 손으로 복사된 분할이 어긋나면 -터미널 크기가 틀어지거나 모든 마우스 클릭이 한 행씩 밀린다. - -프로젝트 탭 행은 탭 개수와 무관하게 **항상 존재한다**. 행이 생겼다 사라지면 -프로젝트를 열고 닫을 때마다 모든 PTY가 resize되는데, 이는 notice row를 -별도 행이 아닌 오버레이로 둔 것과 같은 이유다. 고정 행은 시작 시 pane당 -SIGWINCH 한 번으로 끝난다. +크롬 행 불변식 셋: -탭 행과 notice row는 `draw`의 레이아웃 분기 **이전에** 렌더된다. fullscreen -모드에서 탭이 사라지면 사용자가 자기가 어느 프로젝트에 있는지 알 방법이 -없어지므로, 분기마다 중복 렌더하는 대신 구조로 보장한다. +- **네 행 분할은 `ui::chrome::chrome_rows` 한 곳에서만 계산된다.** `draw`와 세 개의 geometry + helper(PTY 사이저, upper-panel/hint-bar hit test)가 정확히 같은 셀에 떨어져야 하므로, 손으로 + 복사된 분할이 어긋나면 터미널 크기가 틀어지거나 모든 마우스 클릭이 한 행씩 밀린다. +- **프로젝트 탭 행은 탭 개수와 무관하게 항상 존재한다.** 행이 생겼다 사라지면 프로젝트를 열고 닫을 + 때마다 모든 PTY가 resize되는데, notice row를 별도 행이 아닌 오버레이로 둔 것과 같은 이유다. +- **탭 행과 notice row는 `draw`의 레이아웃 분기 이전에 렌더된다.** fullscreen에서 탭이 사라지면 + 사용자가 어느 프로젝트에 있는지 알 수 없어지므로, 분기마다 중복 렌더하는 대신 구조로 보장한다. -The lower panel shows every *visible* pane simultaneously in a balanced -grid instead of switching between tabs — see "Split-View Terminal Panel" -below for the layout and resize rules. +하단 패널은 탭 전환이 아니라 balanced grid로 *보이는* 모든 pane을 동시에 그린다 — +[terminal.md](architecture/terminal.md) 참고. ## Module Structure 모든 소스 파일은 300줄 이하(LOC 규칙, `.agents/rules/guardrails.md` 참고). 테스트는 -`#[cfg(test)] mod tests;`로 별도 파일에 분리한다. +`#[cfg(test)] mod tests;`로 별도 파일/디렉터리에 분리한다(아래 트리에서는 생략). ``` src/ -├── main.rs # entry point: dispatch to daemon / attach / init -├── cli.rs # Cli/Commands, run_daemon/run_init -├── daemon/ # the session socket: framing, protocol, accept loop, -│ # single-instance lock, attaching client, and the -│ # watcher that is the only sender of the repo set +├── main.rs # entry point: dispatch to daemon / attach / serve / init +├── cli.rs, cli/ # Cli/Commands, run_daemon/run_init, `nightcrow plugin` ├── test_util.rs # #[cfg(test)] git fixture helpers shared across modules +├── daemon/ # the session socket +│ ├── socket.rs, lock.rs, detach.rs # 0600 socket + stale handling, flock single- +│ │ # instance lock, backgrounding by re-exec (not fork) +│ ├── frame.rs, protocol.rs, wire.rs # framing (control vs terminal output), +│ │ # Client/ServerMessage JSON, locked write + read-side sort +│ ├── serve.rs, client.rs, clients.rs, requests.rs # accept loop, the attaching side, +│ │ # the attached set + what each has been told, request handling +│ ├── watch.rs # the ONLY sender of the repo set (see session.md) +│ └── terminals.rs, terminal_link.rs # subscribe a client to every repo's hub; +│ # demultiplex terminal traffic per repository ├── application/ # attached TUI orchestration -│ ├── attach.rs # `nightcrow attach`: connect, then run the TUI -│ ├── session_link.rs # the client's half of the daemon-owned tab list +│ ├── attach.rs, session_link.rs # `nightcrow attach`; the client's half of the +│ │ # daemon-owned tab list │ ├── terminal_guard.rs # raw mode + alternate screen, restored on the way out -│ ├── bootstrap.rs # single-project App construction + startup commands -│ ├── event_loop.rs # main_loop: poll/render/input drain -│ ├── splash.rs # first-run splash overlay loop -│ ├── input/ # terminal/browser input routing -│ │ ├── dispatch.rs # key dispatch, prefix follow-up, KeyOutcome -│ │ ├── handlers.rs # ViewMode-specific key handlers (upper/terminal/overlay) -│ │ ├── mouse.rs # click/scroll/swap-target routing -│ │ └── paste.rs # terminal/search/dialog paste routing -│ └── tests/ # application-level input and workspace tests -├── platform/ # OS-adjacent services shared by domain layers -│ ├── logging.rs # tracing-based file logger (rotation + retention) -│ ├── paths.rs # shell-independent tilde expansion -│ └── threading.rs # bounded worker-thread reaping -├── app.rs # App struct + type defs (NoticeKind/ViewMode/Focus/AutoFollow) -├── app/ -│ ├── app_impl.rs # App core methods: new, notice, prefix/swap state -│ ├── auto_follow.rs # idle-driven jump to freshest hot file -│ ├── commit_log_fetch.rs # background commit-log page fetcher (worker thread + poll) -│ ├── commit_log_pagination.rs # CommitLogPagination struct + Drop -│ ├── commit_log_apply.rs # apply_tail_page, apply_refresh_page -│ ├── diff_load.rs # diff loaders, apply_diff_result, refresh_diff -│ ├── file_view_load.rs # file-view loaders, toggle, commit diff loading -│ ├── focus.rs # focus jumps, cycling, fullscreen toggles -│ ├── navigation.rs # status-mode selection, j/k, filtered status -│ ├── log_nav.rs # log-mode search, drill-in/out, cursor movement -│ ├── scroll.rs # upper-panel horizontal scroll helpers -│ ├── session_io.rs # save/restore session state -│ ├── snapshot_io.rs # poll_snapshot: drain SnapshotChannel, detect HEAD change -│ ├── terminal_ctrl.rs # poll_terminal, open/close/swap pane, scroll, fullscreen -│ ├── tree.rs # tree-navigator: mode entry, cache, watcher wiring -│ ├── tree_nav.rs # tree cursor, expand/collapse, search -│ └── tests/ # integration tests split by feature area -├── config.rs # config.toml root: Config, load/validate/init, pub use re-exports -├── config/ -│ ├── layout.rs # LayoutConfig, ThemeConfig, Accent, InputConfig, parse_leader -│ ├── log.rs # LogConfig, LogRotation, LogLevel -│ ├── panels.rs # AgentIndicatorConfig, TreeConfig, MouseConfig -│ ├── web.rs # WebMirrorConfig, WebViewerConfig, password bootstrap -│ └── tests/ # config tests split by section +│ ├── bootstrap.rs, event_loop.rs, splash.rs # App construction + startup commands, +│ │ # main_loop (poll/render/input drain), first-run overlay +│ └── input/ # dispatch, ViewMode handlers, prefix follow-up, +│ # mouse, paste, repo-dialog keys +├── platform/ # OS-adjacent services shared by domain layers: +│ # logging.rs (file logger, rotation + retention), paths.rs +│ # (tilde expansion), signals.rs (SIGINT/SIGTERM shutdown), +│ # threading.rs (try_timed_join) +├── app.rs, app/ # App struct + per-feature impls: auto_follow, commit-log +│ # fetch/pagination/apply, diff & file-view loaders, focus, +│ # navigation, log_nav, scroll, session_io, snapshot_io, +│ # terminal_ctrl, tree, tree_nav +├── config.rs, config/ # config.toml root + layout/theme/input, log, panels, +│ # plugin ([[plugin]]), web (WebViewerConfig, password bootstrap) ├── workspace/ -│ ├── mod.rs # Workspace: open projects (Vec) + active index, -│ │ # process-level repo dialog/notice +│ ├── mod.rs # Workspace: open projects (Vec) + active index │ ├── accent.rs # the session's accent, adopted from the daemon -│ ├── repo_input.rs # o repo-input modal state -│ ├── path_complete.rs # Tab 경로 완성 (read_dir 한 단계, 디렉터리만) -│ ├── path_tree.rs # ↓ 디렉터리 브라우저 상태 (평면 row 리스트) -│ ├── repo_picker.rs # 필드 ↔ 브라우저 전환 -│ ├── persistence.rs # workspace + per-repo state (~/.nightcrow/workspace.json) -│ └── tests/ # workspace + repo_input + repo_picker tests +│ ├── repo_input.rs, repo_picker.rs # o 모달 상태; 필드 ↔ 브라우저 전환 +│ ├── path_complete/ # Tab 경로 완성 (read_dir 한 단계, 디렉터리만) +│ ├── path_tree/ # ↓ 디렉터리 브라우저 상태 (평면 row 리스트) +│ └── persistence.rs # workspace + per-repo state (~/.nightcrow/workspace.json) ├── runtime/ -│ ├── mod.rs -│ ├── snapshot.rs # SnapshotChannel: background git status/log worker +│ ├── snapshot.rs, snapshot/ # SnapshotChannel + the reader thread (worker.rs) +│ ├── snapshot_watch.rs # recursive worktree watch: read on change, not on a timer │ ├── tree_watch.rs # notify-based watcher for expanded tree directories -│ ├── emulator/ -│ │ ├── mod.rs # PaneEmulator: alacritty_terminal wrapper, ScrollSink -│ │ ├── modes.rs # PaneModes: the modes a program set, and the prelude that restores them -│ │ └── view.rs # ScreenView/CellView: grid read access, color mapping -│ └── terminal/ -│ ├── mod.rs # TerminalState struct, constants, PaneInfo, TerminalFullscreen -│ ├── state.rs # accessors: active_pane_id, max_visible, sync_visible_window -│ ├── scroll.rs # scroll_active, scroll_pane, click_pane, sync_scroll -│ ├── lifecycle.rs # poll, create/close/swap pane, resize, send_input -│ └── escape.rs # strip_escape_sequences + consume_* helpers +│ ├── emulator/ # PaneEmulator (alacritty_terminal), PaneModes, ScreenView +│ └── terminal/ # TerminalState: state, scroll, lifecycle, input, +│ # session_panes (close/reorder), recovery, escape strip ├── ui/ │ ├── mod.rs # root layout: draw, draw_empty, pub use re-exports -│ ├── chrome.rs # ChromeRows, chrome_rows, Chrome, main_content_constraints -│ ├── helpers.rs # shared widget/style helpers (status_color, char_offset, etc.) +│ ├── chrome.rs # ChromeRows, chrome_rows, main_content_constraints +│ ├── helpers.rs # shared widget/style helpers (status_color, char_offset, …) │ ├── notice.rs # notice row + repo header rendering -│ ├── hint_text.rs # hint literal constants, normal_hint_literal, prefix_armed_hint_text -│ ├── hint_bar.rs # hint bar render, segment_click, HintClick, hint_click_at +│ ├── hint_text.rs, hint_bar.rs # hint literals; render, segment_click, hint_click_at │ ├── hit_test.rs # pane_at, tab_click_at, upper_panel_at, terminal_content_areas -│ ├── status_view.rs # status-mode state (file filter, search query/cache) -│ ├── log_view.rs # log-mode state (commits, drill-down, file selection) -│ ├── tree_view.rs # tree-mode state (child cache, expanded set, search index) -│ ├── file_list.rs # upper-left: changed files with hot-stage coloring -│ ├── commit_list.rs # upper-left (log view): commit list with ahead marker -│ ├── tree_list.rs # upper-left (tree view): indented directory-tree rows -│ ├── file_view.rs # full-file preview state (content, scroll, syntect cache) -│ ├── search.rs # SearchQuery newtype (query + lowercased form in lockstep) -│ ├── splash.rs # first-run splash overlay -│ ├── diff_pane/ # DiffPane: hunks, scroll, search, file_view sub-state -│ ├── diff_viewer/ # upper-right: diff widget; toggleable file preview -│ ├── terminal_tab/ # lower: terminal pane grid + tab bar widget -│ ├── project_tab/ # project tab row rendering + click targets -│ └── tests/ # ui integration tests (chrome, hint, hit-test, notice) +│ ├── status_view.rs, log_view/, tree_view/ # per-ViewMode state (filter/search cache, +│ │ # commits + drill-down, child cache + expanded set) +│ ├── file_list.rs, commit_list/, tree_list.rs # the three upper-left row renderers +│ ├── path_tree.rs, file_view.rs, search.rs, splash.rs, wall_clock.rs # repo-dialog +│ │ # browser, file preview state, SearchQuery newtype, first-run +│ │ # overlay, unix epoch → HH:MM without a date crate +│ ├── diff_pane/, diff_viewer/ # DiffPane state (hunks/scroll/search/split); the +│ │ # upper-right widget, gutter, split view, file preview +│ └── terminal_tab/, project_tab/ # pane grid + tab bar + recovery markers; +│ # project tab row rendering + click targets ├── backend/ │ ├── mod.rs # TerminalBackend trait + BackendEvent -│ ├── identity.rs # PaneToken / PaneGeneration: a pane slot's name outside this process +│ ├── identity.rs # PaneToken / PaneGeneration: a pane's name outside this process │ ├── slot.rs # per-slot bookkeeping (launch, idle clock) + resume arg validation -│ ├── pty.rs # PtyBackend (portable-pty, the only backend) -│ └── pty_spawn.rs # open_pane / relaunch_pane: the spawn path and env injection -├── plugin/ # provider-agnostic plugin host — see "Plugin Host" -│ ├── mod.rs # module root; states the trust posture +│ ├── pty.rs, pty_spawn.rs # PtyBackend (owns its children); spawn path + env injection +│ └── hub.rs # HubBackend: the same trait over the daemon socket; owns nothing +├── plugin/ # provider-agnostic plugin host (mod.rs states the trust posture) │ ├── protocol.rs # NDJSON wire contract (events out, commands in) -│ ├── host.rs # one long-lived child per plugin -│ ├── host_pump.rs # stdin/stdout/stderr pump threads + capped line reader +│ ├── host.rs, host_pump.rs # one long-lived child per plugin; pumps + capped reader │ ├── guard.rs # the trust boundary: PluginCommand -> Approved | Refused │ ├── guard_budget.rs # per-slot rate ceilings, keyed by PaneToken │ ├── guard_watch.rs # the one rule that can widen what a plugin sees +│ ├── guard_refusal.rs, guard_text.rs # refusal reasons; bounding plugin-supplied text │ └── registry.rs # ~/.nightcrow/plugins: install / list / remove ├── git/ -│ ├── mod.rs -│ ├── diff.rs # module root: pub use re-exports, MAX_FILE_VIEW_BYTES -│ ├── diff/ -│ │ ├── types.rs # StatusKind, ChangedFile, DiffHunk, CommitEntry, RepoSnapshot -│ │ ├── snapshot.rs # load_snapshot, status_columns, path extraction -│ │ ├── diff_load.rs # load_file_diff, load_commit_diff, collect_hunks -│ │ └── commit_log.rs # load_commit_log, load_commit_log_from, head_commit_oid -│ ├── path/ -│ │ └── mod.rs # repo-relative path validation before any filesystem read -│ └── tree/ -│ └── mod.rs # lazy read-only directory listing (gitignore filter, symlink guard) -├── input/ -│ ├── mod.rs # Action enum, pub use re-exports -│ ├── routing.rs # map_key, prefix_action, prefix_action_fullscreen, vim j/k -│ └── encode.rs # encode_key, encode_wheel/button/arrow, CSI/SS3 helpers -└── web/ # optional browser surface — see "Web Viewer" - ├── mod.rs # module root - ├── common/ # server-agnostic primitives (no git or terminals) - │ ├── mod.rs # module root - │ ├── auth.rs # Argon2 password verify, session tokens, login rate limit - │ ├── http.rs # minimal HTTP request parse (path + query) + response builders - │ ├── sse.rs # SseStream: streaming text/event-stream responses - │ └── conn.rs # ConnectionSlot: accept-loop connection accounting +│ ├── diff.rs, diff/ # types, snapshot loader, diff/commit loaders, commit_log, refs +│ ├── clone.rs, clone/ # delegate `git clone` to the binary; URL scheme whitelist +│ ├── path/ # repo-relative path validation before any filesystem read +│ └── tree/ # lazy read-only directory listing (gitignore filter, symlink guard) +├── input/ # Action enum (mod.rs), routing.rs (map_key, prefix_action, +│ # prefix_action_fullscreen, vim j/k), encode.rs (encode_key, +│ # encode_wheel/button/arrow, CSI/SS3 helpers) +└── web/ # browser surface + ├── common/ # server-agnostic primitives (no git or terminals): auth.rs + │ # (Argon2 verify, session tokens, login rate limit), http.rs, + │ # sse.rs (SseStream), conn.rs (ConnectionSlot accounting) └── viewer/ # native web viewer ([web_viewer] / `serve`) ├── limits.rs # ceilings: log page, tree entries, diff bytes/lines, PTYs ├── dto/ # whitelisted wire types + PROTOCOL_VERSION envelope - ├── catalog/ # opaque repo ids, atomic swap, per-repo entries + ├── catalog/ # opaque repo ids, atomic swap, ordering, config tables ├── runtime/ # per-repo thread: SnapshotChannel drain + conflated SSE fan-out ├── terminal/ # per-repo TerminalHub owning its own PtyBackend - ├── highlight.rs # syntect/two-face highlight spans for diff + file payloads - ├── prefs/ # ~/.nightcrow/viewer.json: session accent, sidebar width, active project - ├── reload.rs # re-read config.toml into a live session (both transports) - ├── size_owner.rs # which screen the session's PTYs are fitted to - ├── server/ # HTTP routes, SSE, /ws/term - └── assets.rs # rust-embed of viewer-ui/dist + CSP -``` - -## Key Design Decisions - -### TerminalBackend Trait - -`TerminalBackend`는 pane 추상화다. 구현체가 둘이고, 둘의 차이가 이 trait의 모양을 정했다. - -```rust -trait TerminalBackend { - fn create_pane(&mut self, rows: u16, cols: u16, command: Option<&str>) -> Result<()>; - fn destroy_pane(&mut self, id: PaneId); - fn send_input(&mut self, id: PaneId, data: &[u8]) -> Result<()>; - fn resize(&mut self, id: PaneId, rows: u16, cols: u16); - fn reorder(&mut self, order: &[PaneId]); // 기본 no-op - fn claim_size(&mut self); // 기본 no-op - fn drain_events(&mut self) -> Vec; - // Created / Output / Exited / Resized / SizeOwnership / Reordered -} + ├── session.rs, reload.rs, size_owner.rs # transport-independent session ops; + │ # live config.toml re-read; which screen the PTYs are fitted to + ├── prefs/ # ~/.nightcrow/viewer.json: accent, widths, active repo, maximized + ├── clone_jobs.rs, clone_jobs/ # in-flight clone tracking (one at a time) + ├── highlight.rs, assets.rs # syntect spans for payloads; rust-embed of dist + CSP + └── server/ # HTTP routes, dispatch, mutations, SSE, /ws/term ``` -- `PtyBackend`: portable-pty로 PTY를 만들고 reader 스레드가 출력·Exited를 채널로 푸시한다. - 터미널 허브가 **구체 타입으로 소유**하며 `open_pane`으로 id를 직답받는다 — 만든 즉시 - 등록해야 하기 때문이다. -- `HubBackend`(`backend/hub.rs`): 데몬 소켓 위에 얹은 같은 trait. 저장소당 하나이고 attach - 연결을 공유한다. 아무것도 소유하지 않고 요청한다. - -**소유하지 않는다는 사실이 trait을 세 군데 바꿨다.** - -1. **pane은 반환값이 아니라 이벤트로 온다.** id는 PTY가 실제로 사는 곳에서 나오고, 남이 연 - pane도 같은 경로로 와야 한다. `create_pane`은 "요청"이고 `BackendEvent::Created`가 도착을 - 알린다. 이벤트가 `requested`를 실어 **내가 연 pane만** 포커스를 가져간다 — 어느 pane을 보고 - 있는지는 클라이언트 각자의 일이다. 제목도 같은 규칙으로 큐에 대기했다 도착 시 붙는다. -2. **크기는 이 클라이언트가 정하는 것이 아닐 수 있다**(아래 세션 공유 참고). `Resized`를 따라가고, - 소유하지 않으면 `resize`를 보내지 않는다. -3. **순서도 세션의 것이다.** `swap_active_with`는 `reorder` 요청이고, `panes`는 `Reordered`가 - 투영하는 서버 canonical order다. -4. VT 에뮬레이션은 어느 쪽이든 **클라이언트가 한다** — `PaneEmulator`가 소켓에서 온 바이트를 - PTY에서 온 것과 똑같이 먹는다. 뷰어에서 xterm.js가 서 있는 자리와 같다. - -- **Pane 생명주기 단일 owner**: `drain_events`는 보고만 하고 제거하지 않는다. `Exited`를 받은 쪽이 - `destroy_pane`을 호출해 PTY를 놓는다 — 클라이언트에서는 `TerminalState::poll`, 허브에서는 워커 - 루프다(허브가 그것을 빼먹어서 스스로 끝난 pane의 master fd가 새고 있었다. 캡은 live pane만 - 세므로 열고 끝내기를 반복하면 무한히 쌓였다). -- **닫기와 순서도 요청이다.** `close_active`는 pane을 그 자리에서 지우지 않고 `Exited`를 기다린다 — - 세션이 실행하지 않은 닫기(커맨드 큐가 꽉 찬 경우)가 있으면 프로세스는 살아 있는데 이 클라이언트만 - 그 pane을 영영 못 보게 된다. 남의 클라이언트가 닫은 pane이 오는 경로와 같은 경로다. -- **세션이 시작 터미널의 이름을 준다.** `[[startup_command]] name`(없으면 커맨드 텍스트)이 `Created`에 - 실려 모든 클라이언트가 같은 이름을 쓴다. 클라이언트가 직접 연 pane은 이름 없이 오고(그 클라이언트가 - 이름을 안다), 어느 쪽이든 OSC 0/2가 나중에 덮어쓴다. - -### 세션 공유 (데몬 ↔ 클라이언트) - -무엇이 공유이고 무엇이 클라이언트별인지가 이 앱의 중심 결정이다. 전부 공유하면 브라우저에서 -커서를 내릴 때 TUI 커서도 내려가 "디스플레이별 렌더링"이 의미를 잃고, 전부 로컬이면 같은 세션에 -붙은 두 화면이 서로 다른 것을 보여준다. - -- **공유(데몬 소유)**: 저장소 집합과 순서, **활성 프로젝트**, 터미널 pane 집합·내용·순서·크기, - 그리고 **accent** -- **뷰어 안에서만 공유(브라우저 간, TUI와는 공유 안 함)**: 사이드바 폭(`sidebar_width`), 터미널 - 패널 높이(`upper_pct`). 둘 다 `viewer.json`에 살지만 attach한 TUI는 읽지 않는다 — 앞은 TUI에 - 대응 값이 없어서, 뒤는 대응 값(`config.layout.upper_pct`)이 있어도 공유가 틀린 답이어서다 - (아래 "터미널 패널 높이도 divider 드래그로 조절한다" 참고). -- **클라이언트별**: 뷰 모드(status/log/tree), 커서·선택·스크롤, 포커스, fullscreen, 검색 텍스트 - -**accent는 원래 클라이언트별이었다.** TUI는 저장소별로 기억해 색으로 탭을 구별했고, 뷰어는 자기 -표면 안에서만 기기 간에 공유했다. 뒤집은 이유는 한 세션에 표면이 여럿이라는 사실이 그 편의보다 -무겁기 때문이다 — TUI와 브라우저를 나란히 두면 같은 세션이 두 색으로 보였고, 어느 쪽이 이 세션의 -색이냐는 물음에 답할 수 있는 값이 아예 없었다. 저장소별 색이 대신하던 "지금 어느 프로젝트인가"는 -탭 이름과 활성 탭 강조가 이미 답한다. 이제 값은 `viewer.json` 하나에 살고(`web/viewer/prefs`), -어느 표면에서 바꾸든 세션 전체가 따라온다 — 대신 프로젝트를 바꿔도 색은 그대로다. `[theme] name`은 -아직 한 번도 색을 고르지 않은 세션의 시작색으로 남는다. - -**데몬이 세션을 감시한다**(`daemon/watch.rs`). 세션에는 문이 둘이다 — 브라우저의 HTTP 핸들러와 -attach 소켓 — 그래서 브라우저에서 연 저장소는 attach 소켓의 아무것도 깨우지 않는다. watcher -스레드가 틱마다 세션을 다시 읽어 마지막으로 알린 것과 다르면 브로드캐스트한다. **알림(callback)이 -아니라 관측인 이유**: 알림은 나중에 추가된 mutation이 빼먹을 수 있고, 그 실패가 정확히 "브라우저 -변경이 TUI에 안 닿는" 버그로 다시 나타난다. 그래서 브로드캐스트하는 곳이 하나이고(클라이언트가 -무엇을 아는지에 대한 기록이 하나), 새로 생긴 저장소의 터미널을 모든 클라이언트에 구독시키는 -것도 여기다 — 소켓을 읽는 스레드는 `read`에 막혀 있어 할 수 없다. attach 클라이언트의 요청은 -watcher를 **즉시 깨우므로**(`Nudge`) 키 입력이 폴링 간격을 기다리지 않는다. - -**세트를 보내는 곳도 watcher 하나다.** 붙는 클라이언트도, 세트를 직접 물어본(`ListRepos`) -클라이언트도 자기가 보내지 않고 "아직 못 받았다"고 등록만 하고 watcher를 깨운다 -(`clients.rs`의 `owed_set`). 한 큐에 생산자가 하나면 **프레임 순서가 곧 상태가 바뀐 순서**이기 -때문이다. 전에는 attach 스레드와 watcher가 각자 보냈고, 둘 사이에 변경이 끼면 새 프레임이 옛 -프레임보다 앞에 큐잉되어 갓 붙은 클라이언트가 다른 모두가 떠난 상태에 남았다 — watcher는 이미 -"모두에게 알렸다"고 기록했으므로 다시 말해주지도 않았다. 순서를 락으로 맞추는 대신 경쟁을 -없애는 쪽인데, 뷰어의 preference 쓰기(`serialWrite.ts`)와 탭 순서 변경이 이미 같은 결론에 -도달해 있다. 그래서 watcher를 띄우지 못하면 데몬은 **시작하지 않는다**(`serve::start`) — -watcher 없는 세션은 클라이언트에게 무엇이 열려 있는지 영영 말해주지 못한다. - -**PTY 크기는 한 클라이언트가 정한다.** PTY는 데이터가 아니라 자식 프로세스와 맺은 계약이다 — -자식은 들은 폭에 맞춰 그리고, alternate screen을 쓰는 풀스크린 TUI를 나중에 다시 흘릴 방법은 -없다. 그래서 tmux의 `window-size latest`와 같은 모델을 쓴다: **뷰어의 도착이 곧 소유권 이전**(방금 -앉은 화면에 맞춘다), 이미 붙어 있으면 `claim_size`로 명시적 탈취(TUI ` z`, 뷰어의 -"fit to this screen" 아이콘 버튼), 소유자가 떠나면 남은 중 가장 최근에게, 아무도 없으면 마지막 크기 유지. - -**소유권은 hub별이 아니라 세션 하나가 갖는다**(`web/viewer/size_owner.rs`). 어느 repo가 앞에 -있는지는 세션 공유라 모든 클라이언트가 같은 것을 본다. 따라서 "이 세션은 어느 화면에 맞춰져 -있나"는 질문이 하나지 repo 수만큼 있는 게 아니다. hub마다 따로 답하던 때는 전환할 때마다 그 -답을 처음부터 다시 뽑았고 — 브라우저의 터미널 소켓은 보고 있는 repo에 묶여 있어서 탭을 옮기면 -붙어 있는 모든 페이지가 동시에 재접속한다 — 소유권이 **핸드셰이크가 늦게 끝난 쪽**으로 갔다. - -**뷰어는 커넥션이 아니다.** `접속 = 소유자 도착`은 소켓이 열렸다는 사실에서 의도를 읽어내는 -것인데, 소켓은 사람이 앉는 것 말고도 열린다: repo 전환, 새로고침, 네트워크 끊김. 그래서 뷰어는 -자기 이름을 대고(`ViewerId` — 브라우저는 탭당 id, attach한 TUI는 데몬 client id 하나로 모든 repo -구독을 묶는다) **방금 도착했는지를 직접 말한다**. 세션은 그것을 추론하지 않는다. 커넥션은 뷰어 -아래에서 오가되 아무것도 움직이지 않는다. - -브라우저는 `sessionStorage`에 탭당 id를 두고(`lib/viewerId.ts`) `/ws/term`에 `viewer=`로 실어 -보내며, 페이지가 처음 뜨는 한 번만 `claim=1`을 붙인다. `localStorage`가 아닌 이유는 그것이 -탭별이 아니어서 한 브라우저의 두 탭이 한 뷰어가 되고 서로에게서 소유권을 가져올 수 없게 되기 -때문이다. `viewer=`가 없거나 형식이 어긋나면 서버가 일회용 id를 발급한다 — 거부가 아니라, -이름을 대기 전의 동작으로 강등된다(캐시된 옛 번들이 유력한 이유다). - -**해제에는 유예가 있다**(`RELEASE_GRACE`, 2초). repo를 옮기면 소켓 하나가 닫히고 다른 하나가 -열리는데, 그 사이의 공백은 부재가 아니다. 거기서 소유권을 넘겼다 돌려받으면 alternate screen -프로그램에 repaint를 왕복으로 물린다. 유예를 끝내는 것은 hub worker의 tick(`settle`)이다 — -전용 타이머를 두지 않는 이유는, 볼 사람이 있으려면 hub가 돌고 있어야 하기 때문이다. -비소유자의 resize는 버려지고 **실제 적용된 크기가 브로드캐스트된다** — 관전자의 에뮬레이터도 -자식이 감는 곳에서 감아야 하기 때문이다. 소유자도 그것을 읽지만("clamp됐다"를 그렇게 안다) -"내가 요청한 값" 기록은 유지한다. 그러지 않으면 매 프레임 같은 clamp를 다시 요청한다. -입력마다 소유권을 옮기는 대안은 기각했다 — 폰으로 잠깐 확인하는 제일 가벼운 행동이 전체 -repaint를 유발하는 제일 비싼 행동이 된다. 부수 효과로 **비소유 클라이언트가 곧 관전자**여서 -별도의 관전 모드가 필요 없고, 영역과 그리드가 다르면 렌더 경로가 clamp로 처리한다(작으면 -여백, 크면 잘라냄). - -**상태는 시간이 아니라 변화에 따라 읽는다** (`runtime/snapshot_watch.rs`). `git status` 한 번은 -측정값으로 파일 260개 저장소에서 3 ms, 1만 개에서 23 ms, 5만 개에서 **129 ms**다. 이것을 1초마다 -돌리면 아무 일도 없는 시간에도 그만큼을 태운다. 그래서 워크트리를 **재귀 감시**하고 변화가 있을 때만 -읽는다. 옆의 트리 워처가 재귀 감시를 거부한 것과 다른 결론인데, 이유가 다르다 — 트리 뷰는 펼친 -디렉토리만 필요해서 재귀가 낭비지만, status는 트리 전체가 대상이라 더 작은 감시 집합이 없다. 남는 -위험(리눅스 inotify 디스크립터 소진)은 **설치 실패 시 예전의 1초 폴링으로 폴백**해서 받는다. 이 -폴백은 **끈적하다** — 실패를 1초마다 재시도하면 트리를 다시 걷고 같은 경고를 세션 내내 초당 한 줄씩 -남기는데, 실패 원인(watch 상한, 권한)은 1초 뒤에 달라지지 않는다. 재시도는 아무도 안 보던 저장소를 -다시 볼 때만 일어난다. - -세 가지 상한이 이것을 안전하게 만든다: -- **읽기 간격 하한 1초** — 이벤트가 폭주해도(git이 무시하지 않는 파일을 쏟아내는 빌드) 비용이 - 정확히 예전 폴링과 같고 절대 그보다 크지 않다. -- **10초 상한** — 이벤트를 놓쳤거나 트리 일부에만 감시가 걸렸을 때 "사용자가 다른 걸 건드릴 때까지 - 낡음"이 되지 않게. 감시가 아예 없으면 이 값은 쓰이지 않고 1초 폴링이 된다. -- **git이 무시하는 경로는 읽지 않는다** — 빌드 산출물은 워크트리에서 가장 시끄럽고 status에 - 나타날 수 없는 유일한 것이다. pane에서 빌드가 도는 것이 이 앱의 평상시 상태라 이 필터가 이 변경을 - 실제로 값어치 있게 만든다. `-f`로 추가된, 무시 디렉토리 안의 추적 파일은 이것이 잘못 건너뛰는 - 유일한 경우이고 10초 상한이 잡는다. - -**아무도 안 보는 저장소는 걷지도 감시하지도 않는다.** 구독자가 없으면 읽기와 감시를 함께 끈다 -(`SnapshotChannel::watch`) — 아무도 보지 않는 트리 때문에 감시 디스크립터를 붙들지 않는다. 데몬이 -여는 워커는 **처음부터 잠든 채로 시작한다**(`spawn_asleep`) — 깨워서 만든 뒤 끄면 워커가 그 사이에 -한 번 읽을 수 있고, 그 읽기는 아무도 요청하지 않은 트리 순회이면서 나중의 더 새로운 읽기 뒤에 발행될 -낡은 값으로 큐에 남는다. 워커는 순회를 **끝낸 뒤에도** awake를 한 번 더 보고 잠들었으면 결과를 넘기지 -않는다 — 큰 트리 순회 시간은 마지막 클라이언트가 떠나기에 충분하고, 아무도 기다리지 않는 읽기는 낭비에 -그치지 않고 다음 클라이언트가 자기 읽기를 발행한 뒤에 그 위로 덮인다. - -**남은 한계**: 워커가 큐에 넣은 읽기와 구독 시점의 즉시 읽기가 겹치면(양쪽 읽기 사이에 트리가 바뀌고, -runtime 스레드의 100 ms 폴 안에 구독이 들어오는 경우) 오래된 쪽이 뒤에 발행될 수 있다. 다음 변화나 -10초 안전망이 바로잡는다. 근본 해결은 읽기마다 시각을 실어 발행 순서를 읽은 순서로 강제하는 것인데, -`SnapshotMsg`가 TUI와 뷰어 양쪽 경로에 걸쳐 있어 그 값이 이 창의 크기에 비해 크다. 첫 -구독자가 오면 다시 켜고 **그 자리에서 한 번 읽어** 답한다: 꺼져 있는 동안의 `latest`는 마지막 -클라이언트가 떠날 때의 상태이고, 다음 날 아침에 연 페이지에는 그것이 낡은 값이 아니라 틀린 화면이다. -`/api/status`도 꺼져 있으면 같은 방식으로 읽는다. 이 켜고 끄기는 **구독자 목록 락을 잡은 채로** -결정한다 — "내가 첫 번째인가"와 "내가 마지막인가"는 같은 목록에 묻는 질문이라, 세었다가 놓고 등록하면 -그 틈에 마지막 클라이언트가 떠나며 읽기를 꺼버리고, 구독자가 붙어 있는데도 아무도 그것을 다시 켜지 -않는 상태가 남는다. 구독자 수는 "읽고 있다"와 같은 사실이 아니다. - -**git 디렉토리가 트리 밖에 있으면 그쪽도 감시한다.** `git worktree add`와 `--separate-git-dir`은 -`.git`을 파일로 남기고 index와 ref를 다른 곳에 둔다. 워크트리만 감시하면 그런 체크아웃에서 `git add`는 -아무 이벤트도 만들지 않아 10초 안전망까지 낡은 채로 남는다. 감시 대상은 `path()`가 아니라 -**`commondir()`**인데, linked worktree의 `path()`에는 자기 index만 있고 ref는 본체 쪽에 있어서 다른 -곳에서 같은 브랜치에 커밋하면 status가 바뀌기 때문이다. 이 두 번째 감시는 저장소 핸들이 있어야 위치를 -물어볼 수 있으므로 **읽기 뒤에** 건다 — 한 번이 아니라 매 읽기마다 위치를 다시 확인하는데, 핸들은 -주기적으로 다시 열리고 옮겨진 체크아웃은 git 디렉토리가 다른 곳에 있는 채로 돌아오기 때문이다. 감시를 -새로 건 직후에는 읽기를 한 번 예약한다: 방금 끝난 읽기는 그 감시가 없던 시점의 것이라 그 사이의 -`git add`는 아무 데도 흔적을 남기지 않는다. 평범한 저장소는 `.git`이 트리 안이라 두 번째 감시를 걸지 -않는다 — 걸면 모든 이벤트가 두 번 온다. - -`objects`/`logs`/`*.lock` 필터는 **git 디렉토리 최상위에만** 적용한다. 서브모듈은 -`modules//` 아래에 자기 git 디렉토리를 갖고 거기서도 같은 churn이 나지만, 서브모듈 이름은 -트리에서의 경로라서 슬래시를 포함한다 — `modules/foo/objects/HEAD`는 `foo/objects`에 있는 -서브모듈의 `HEAD`이기도 하고 `foo`에 있는 서브모듈의 오브젝트 디렉토리이기도 하며, 컴포넌트를 세는 -어떤 방법으로도 구분되지 않는다. 그래서 판단하지 않고 읽는다: 잘못 거르면 아무도 못 보는 변경이 -생기고, 다 통과시켜도 서브모듈 fetch 중에 초당 한 번 더 걷는 것이 전부다(감시하기 전의 비용). - -**macOS는 이벤트 경로를 심링크 해석해서 준다**(`/var/...` → `/private/var/...`). 그래서 감시하는 -디렉토리 경로를 canonical 형태와 원래 형태 양쪽으로 들고 비교한다 — 상대 경로로 만들지 못한 이벤트는 -"판단 불가 → 읽는다"로 떨어지므로, 이걸 틀리면 정확성은 유지되지만 **ignore 필터가 조용히 통째로 -무력화된다**(안 만든 것과 같아진다). - -**이벤트 큐는 한 번에 비운다.** 읽기 한 번(5만 파일에서 129 ms) 동안 빌드는 수천 개의 이벤트를 쌓는데, -루프를 한 바퀴에 하나씩 소비하면 그것들이 메모리에 머물고 뒤에 도착한 **종료 신호도 그 뒤에서 -기다린다**(`Drop`의 join은 5 ms 상한이라 그대로 detach로 떨어진다). 그래서 깨어난 김에 `try_recv`로 -남은 것을 전부 받고, 이미 읽기가 예약된 상태(`changed`)면 경로마다 ignore 여부를 되묻지 않는다 — -답이 무엇이든 다음 행동이 같다. - -**스크롤백 깊이는 두 상한이 만나는 자리다** — 허브는 pane당 바이트 링(256 KiB), 클라이언트는 -줄(1000)로 센다. 평범한 출력에서는 바이트 창이 훨씬 넓어 클라이언트의 줄 상한이 먼저 차지만, -**줄당 ~262바이트를 넘으면 리플레이가 줄 상한을 못 채운다**(토큰마다 색을 바꾸는 하이라이팅이 -거기에 닿는다). 그 지점을 테스트로 고정해 두고 상한은 바꾸지 않았다 — 거기 닿는 출력은 대부분 -텍스트가 아니라 repaint 시퀀스이고, 부족분은 1000줄 중 수백 줄이며, 상한은 저장소×pane마다 -지불된다. - -**붙는 클라이언트에게는 기록이 아니라 상태를 준다**(`web/viewer/terminal/hub_modes.rs`, -`hub_repaint.rs`, `runtime/emulator/modes.rs`). 바이트 링은 역사이지 스냅샷이 아니어서, 창보다 -앞에서 일어난 일은 그 안에 없다. 프로그램이 시작할 때 한 번 켜고 다시 말하지 않는 것들(alternate -screen, 마우스 리포팅, bracketed paste, DECCKM)이 정확히 그 부류다 — 하루 지난 pane에 다시 붙으면 -그 바이트는 이미 밀려나 있고, 클라이언트는 **프로그램이 설정한 적 없는 터미널**이 된다(스크롤·클릭 -죽음, 화살표 인코딩 불일치, 붙여넣기 깨짐). 그래서 허브가 pane당 에뮬레이터를 **모드 확인 용도로만** -돌려(그리드는 읽지 않는다) 현재 모드를 `PaneState`에 적어두고, `connect`가 history보다 **먼저** -`PaneModes::prelude`를 보낸다. 프렐류드는 12개 모드를 h/l로 **전부 명시**한다 — 받는 쪽은 xterm.js고 -그 기본값은 이 에뮬레이터의 것이 아니다(`1007`이 실제로 다르다). 그리고 alternate screen pane은 -**링을 아예 replay하지 않는다**: 그 바이트는 이 클라이언트에 없는 화면에 대한 셀 갱신이라 조각만 -그려지고, 전사(transcript)는 프로그램 자신의 메모리에 있다. 대신 **프로그램에게 다시 그리게 한다** — -크기를 한 행 줄였다가 tick 뒤에 되돌린다. `SIGWINCH`는 크기가 실제로 바뀔 때만 가고, 두 resize를 -연달아 하면 자식의 핸들러가 최종 크기를 읽어 "안 바뀜"으로 보기 때문에(측정함: 같은 `$LINES`가 두 -번) **간격이 필요하다**. 중간 크기는 클라이언트에 알리지 않고(기록된 크기는 그대로, 복원 직후의 -repaint가 전 행을 덮는다), 복원은 그 시점의 기록된 크기로 한다(그 사이 sizing을 가져간 -클라이언트가 마지막 말을 한다). pane당 최소 간격을 두어, 소켓이 계속 끊기는 폰이 재접속 루프로 -repaint를 반복시키지 못하게 한다. **이 전제를 잃었던 것이 실제 사고를 만들었다**: 예전에는 "붙자마자 -클라이언트가 보내는 resize에서 풀스크린 프로그램이 알아서 다시 그린다"에 기대고 있었는데, 리로드 -플리커를 없애려 같은 크기면 resize를 생략하게 되면서(아래 "PTY 크기는 확정된 값만 전달한다") 그 -repaint가 사라졌다. 남은 깨진 화면에 사용자가 누르는 복구 키가 `Ctrl+L`이고, fullscreen 렌더링의 -Claude Code는 그것을 2초 안에 두 번 받으면 `/clear`를 실행한다 — 그렇게 대화가 연달아 지워졌다. - -**입력의 출처를 기록한다**(`web/viewer/terminal/hub_diag.rs`, `session.rs`, -`viewer-ui/src/lib/clearKeyProbe.ts`). 특정 사건 때문에 존재하는 계측이다 — Claude Code가 돌던 -pane에서 5초 사이에 대화가 14번 지워졌는데, fullscreen 렌더링의 Claude Code는 `Ctrl+L`을 2초 안에 -두 번 받으면 `/clear`를 실행하므로 `0x0c`가 30번쯤 기계적인 간격으로 들어왔다는 뜻이고, **무엇이 -보냈는지 알 수 없었다**. nightcrow는 아니다: 합성해서 쓰는 입력은 스크롤·마우스 리포트와 plugin의 -`continue`뿐이고 후자는 그 자리에서 로그를 남긴다. 남는 것은 클라이언트의 입력이고, 그것이 지나는 -자리에 두 가지를 걸어두었다. (1) **도착 기록** — 허브가 `0x0c`가 실린 입력 프레임마다 pane·client -id·개수·동승한 바이트 수·직전 프레임과의 간격·연속 구간 누계를 남긴다. 키보드에서 온 `^L`은 혼자 -오고 paste나 스크립트가 쓴 블록은 그렇지 않으므로, **동승 바이트 수와 간격만으로 모양이 갈린다**. -한 구간에서 40줄까지만 쓰고 나머지는 세기만 하다가 구간이 끝날 때 총계를 한 줄로 남긴다 — 눌린 채 -반복되는 키는 초당 수십 번이라 로그가 스스로를 밀어내기 때문이다. (2) **출처 지문** — 브라우저가 -`0x0c`를 보낼 때 그것을 만든 keydown의 `isTrusted`·`repeat`·`code`·경과 ms를 함께 보고한다. -`isTrusted:false`는 스크립트가 `dispatchEvent`로 만든 것이라 **확장 확정**, `true`+`repeat:true`는 -물리적으로 눌린 채 OS가 반복하는 것, keydown 없이 온 바이트는 paste·IME·스크립트의 직접 주입이다. -폰에는 볼 콘솔이 없으니 보고는 서버 로그로 간다. **입력 내용은 어느 쪽도 기록하지 않는다** — 세는 -것과 타이밍뿐이다. 보고는 클라이언트가 하는 말이므로 페이지가 로그를 마음대로 쓰지 못하도록 -분당 상한을 두고, `code`는 ASCII 영숫자 16자로 깎는다(줄바꿈이 들어오면 로그 한 줄을 위조할 수 -있다). 원인이 특정되면 이 계측은 지운다. - -### Config Reload (`web/viewer/reload.rs`) - -`config.toml`을 고칠 때마다 데몬을 내렸다 올리면 살아 있는 pane이 전부 죽는다 — agent CLI가 -작업 중이던 것까지. 그래서 **두 테이블만 다시 읽는다.** 무엇이 즉시 닿고 무엇이 안 닿는지는 -"그 값을 이미 무엇에 썼는가"가 정한다. - -- **`[[plugin]]` — 열려 있는 모든 프로젝트에 즉시.** plugin은 pane이 아니라 자식 프로세스라 - 교체 비용이 세션에 없다. hub별로 diff한다(`terminal/hub_reload.rs`): 새로 원하게 된 것을 - 띄우고, 아닌 것을 멈추고, **`command`/`args`/`env`가 바뀐 것만** 프로세스를 갈아치운다. - `allowed_resume_flags`·`watch_on_signal`만 바뀌면 살아 있는 자식을 건드리지 않는데, 그 둘은 - 판정마다 이쪽에서 읽는 값이고 plugin은 몇 시간짜리 대기 중일 수 있기 때문이다. -- **`[[startup_command]]` — 이후에 여는 프로젝트부터.** hub는 startup pane을 자기 수명에 - **딱 한 번** 만든다(`started: AtomicBool`). 이미 열린 프로젝트가 그 목록에 쓴 pane은 살아 있는 - 자식이라, 파일 편집을 근거로 교체할 수 있는 대상이 아니다. Catalog의 목록만 바뀌고 - (`catalog/config_tables.rs`) 그 뒤 `rebuild`가 띄우는 hub가 새 목록을 받는다. -- **나머지는 재시작이 필요하다**: `[web_viewer]`(리스너가 이미 바인드됨), `[log]`, 그리고 - 클라이언트 소유인 `[layout]`·`[input]`·`[tree]`·`[mouse]`(attach 시 각 TUI가 읽는다). - -**전송 계층에 독립적이다.** `session.rs`와 같은 자리에 같은 이유로 둔다 — 브라우저는 -`POST /api/reload`, attach한 TUI는 `ClientMessage::ReloadConfig`로 닿고, 둘이 **같은 상태 변경**에 -착지해야 한다. 여기서 인증하지 않는 것도 `session.rs`와 같다(누가 물어볼 수 있는지는 각 전송이 -정한다: 한쪽은 세션 쿠키, 다른쪽은 0600 소켓의 주인이라는 사실). 요청은 **아무것도 실어 나르지 -않는다** — 파일 자체가 요청이다. 내용을 실어 보내게 하면 클라이언트가 지어낸 설정으로 세션을 -재구성할 수 있고, 이 방식이면 데몬은 언제나 사용자가 쓴 디스크의 파일만 읽는다. - -**절반만 적용되지 않는다.** 파일 전체를 파싱·검증한 뒤에야 아무것이든 건드리므로, 어디의 오타든 -세션은 그대로 남고 메시지가 틀린 키를 지목한다. **파일이 사라진 경우는 거부한다** — 시작 시에는 -"아직 설정 없음"이 정상 상태지만 reload 시점에는 실수이고, 기본값으로 읽으면 파일을 지우고 -reload하는 것이 모든 plugin을 조용히 멈추는 경로가 된다. `--exec` pane은 파일에 없으므로 Catalog가 -따로 기억해 다시 병합한다(`config::merge_startup_commands`). - -**hub에서 무엇이 plugin을 원하는지는 그 hub의 opt-in으로 판정한다** — 새 파일의 것이 아니다. -편집으로 추가된 `[[startup_command]]`는 이미 뜬 hub에 pane이 없고 앞으로도 생기지 않으니, 그것이 -가리키는 plugin을 띄우면 영영 아무것도 받을 수 없는 자식 프로세스가 된다. 반대로 **살아 있는 pane을 -보고 있는 plugin은 아무것도 그것을 지명하지 않아도 유지한다**: 파일에서 opt-in을 지워도 pane은 -없어지지 않고, 살아 있는 agent 터미널을 조용히 감시 해제하는 쪽이 파일이 더는 요청하지 않는 host를 -남기는 쪽보다 나쁘다. 멈추라는 뜻은 `enabled = false`이고 그건 따른다. - -**pane의 opt-in은 host가 없어도 기록한다**(`hub_plugins.rs`의 `intended`). 이것이 세션 중간에 -plugin을 켰을 때 그것이 꺼져 있는 동안 만들어진 pane에 닿게 하는 유일한 경로다 — 이 기능이 존재하는 -이유가 그 경우다. 그 자체로는 아무 권한도 주지 않는다: pane에 실제로 작용하는 것은 `owners`뿐이라, -host 없는 opt-in은 relaunch 경로에도 오르지 않고 이벤트도 받지 않는다(`adopt`가 원래 그런 연결을 -거부하는 이유와 같다). reload로 멈춘 plugin은 pane을 놓아주되 opt-in은 남기므로, **끄고 다시 켜면 -처음 켜는 것과 같은 자리에 착지한다** — 안 그러면 `enabled`가 마지막으로 어느 방향으로 -뒤집혔는지에 따라 다른 뜻이 된다. - -**후계자가 뜨지 못하면 그 pane들도 놓아준다.** 교체는 멈춘 plugin이 살아 있는 pane을 계속 붙잡고 -있는 유일한 경우인데, 그 근거는 곧 후계자가 온다는 것뿐이다. spawn이 실패하면 그 약속을 도로 -거둔다(`Plugins::abandon`) — 안 그러면 host 없는 이름이 pane을 소유한 채로 남고, 그 pane이 다음에 -끝날 때 relaunch 경로에 올라 아무도 부탁할 수 없는 9일짜리 hold가 된다. plugin이 그것 하나뿐인 -hub라면 hold를 만료시키는 per-tick 작업 자체가 돌지 않으므로(`is_inert`), 클라이언트는 영영 오지 -않는 deadline을 향해 카운트다운한다. - -**guard는 절대 재생성하지 않는다.** relaunch 예산은 pane의 token으로 키를 잡는데, 그것이 exit마다 -relaunch로 답하는 plugin을 묶는 유일한 상한이다. reload마다 새 allowance를 발급하면 그 상한에 -영영 닿지 않는다 — `take_over`가 spent budget을 그대로 두는 것과 같은 근거다. - -**relaunch hold는 그것을 쥐고 있던 자식과 함께 죽는다** — 교체든 정지든. hold는 프로세스가 이미 -끝난 pane을 *그 plugin이* 되살릴 수 있도록 붙잡아 둔 슬롯이고, 후계자는 **hub에 아직 남아 있는 -pane만** 건네받는다(`start_host`가 `titles`로 걸러낸다 — 끝난 pane은 그 목록에 없다). 즉 그 token은 -그것을 받았던 자식과 함께 사라진다. 그대로 두면 슬롯이 아무도 이행할 수 없는 9일 창을 끝까지 -앉아 있고, 그동안 모든 클라이언트가 오지 않을 relaunch를 향해 카운트다운한다. - -**plugin을 재시작하면 그 plugin이 진행 중이던 것은 사라진다.** plugin의 상태는 그 프로세스 안에 -살기 때문이다 — `nightcrow-recovery`의 `panes: HashMap`은 메모리뿐이고 디스크에 남기지 않으므로, -재시작하면 quota reset을 몇 시간 기다리던 pane은 감시에서 빠지고 아무것도 그것을 재개하지 않는다. -plugin 자신이 나가면서 몇 개를 포기했는지 로그에 남긴다(`runloop.rs::farewell`이 이미 이 사실을 -전제로 쓰여 있다). host가 대신 경고할 수는 없다 — **살아 있는** pane에 대한 대기는 plugin 안에만 -있고 host의 `pending`에는 없어서, host는 그것이 기다리는 중인지 알 방법이 없다. 그래서 이 손실의 -범위를 좁히는 것이 `spec_changed`의 진짜 값이다: 그 plugin 자신의 `command`/`args`/`env`를 고쳤을 -때만 프로세스가 갈리고, 다른 plugin 추가·startup command 변경·플래그 조정은 대기 중인 자식을 -건드리지 않는다. - -**동시 reload는 직렬화한다**(`ViewerState::reload_lock`). 두 클라이언트가 동시에 누르면 한쪽의 -테이블 교체와 다른쪽의 hub fan-out이 끼어들어, 세션의 저장소들이 서로 다른 파일을 전달받은 상태로 -남을 수 있다. - -**reload와 프로젝트 열기의 경합은 Catalog의 mutation lock이 막는다.** 테이블 교체와 "알려줄 -저장소 목록" 스냅샷을 rebuild가 잡는 것과 **같은 락 안에서** 함께 처리하고, 그 목록을 호출자에게 -돌려준다(`set_config_tables`가 `Vec>`를 반환하는 이유). 없으면 같은 순간에 열린 -저장소가 둘 사이로 빠질 수 있다 — hub는 교체 전 테이블을 읽었는데 스냅샷은 그 entry가 등록되기 -전에 찍히면, 그 hub에게는 아무도 알려주지 않아 열려 있는 내내 이전 `[[plugin]]` 테이블로 돈다. -락을 잡으면 남는 순서는 둘 다 옳은 것뿐이다: 먼저 열려서 스냅샷에 들어오거나, 나중에 열려서 새 -테이블을 읽거나. - -**답은 물어본 클라이언트에게만 간다** — 세트 변경과 달리 브로드캐스트하지 않는다. reload가 하는 -일은 다른 클라이언트가 보고 있는 화면에 아무것도 드러나지 않으므로(startup 목록은 나중에 여는 -프로젝트에만, plugin 교체는 아무도 안 보는 자식 프로세스), 전부에게 알리면 자기가 하지도 않았고 -볼 수도 없는 일에 대한 알림이 된다. 그래서 브라우저에도 화면 변화가 없고 **toast가 피드백 전부**다. -문구는 서버가 만든다(`ReloadReport::summary`) — 같은 reload에 대해 TUI notice와 브라우저 toast가 -다른 말을 하지 않도록. - -**닿지 못한 저장소는 보고에 드러낸다.** hub에게는 명령 큐로 *부탁만* 하므로, 큐가 가득 찬 hub는 -요청을 받지 못한다(worker가 막혔거나 클라이언트에게 두들겨 맞는 중이라는 뜻이다). 막고 기다리면 -그 하나 때문에 세션의 나머지 저장소가 전부 밀리므로 기다리지 않는다. 대신 세지 않고 넘기면 그 -저장소는 이전 plugin 자식을 그대로 둔 채 성공으로 보고되므로, `ReloadReport::unreachable`로 세어 -문장에 `(1 was too busy to be told)`로 덧붙인다. - -### Git Diff Pipeline - -- 백그라운드 worker 스레드: `SnapshotChannel`이 1초 간격으로 `load_snapshot`을 호출해 변경 파일 + tracking status를 `mpsc` 채널로 푸시한다. -- UI 스레드 동기 로드: 파일/커밋 선택이 바뀌면 `load_*_with_repo`를 직접 호출한다. App은 `git2::Repository`를 lazy-cache하므로 매 호출마다 `Repository::discover`를 다시 실행하지 않는다. cache는 프로젝트와 수명을 같이 하므로 무효화 시점이 따로 없다 — 저장소가 바뀌는 유일한 방법이 탭을 닫고 새로 여는 것이기 때문. -- 경로 검증: 워크트리 안의 파일·디렉토리를 여는 경로는 전부 `git::path::resolve_in_workdir`를 거친다(파일 미리보기와 트리 리스팅 양쪽). plain relative 컴포넌트만 허용하고 `..`·절대경로·NUL·`.git`(대소문자 무시)을 거부하며, 워크디렉토리부터 한 컴포넌트씩 내려가 **모든 깊이의 심링크**를 막고 canonicalize containment로 마무리한다. 지금 호출자는 git이 만들어 낸 경로만 넘기지만, 검증을 호출부가 아니라 파일시스템 경계에 두어야 웹 표면이 요청 문자열을 같은 로더에 태워도 안전하다. 크기 검사와 읽기는 같은 파일 핸들에서, 트리 리스팅은 검증기가 돌려준 경로로 `read_dir`을 수행해 check→use TOCTOU를 닫는다. `.git` 판정은 `is_git_dir_name` 하나로 통일한다 — 대소문자와 후행 점·공백(NTFS가 버리는 문자)까지 흡수하며, 규칙을 두 군데에 따로 적으면 그 틈이 우회로가 된다. -- 렌더링: 보이는 행(`scroll_start..scroll_start+visible_height`)에 한해 `syntect`로 syntax highlighting을 수행한다. 보이지 않는 라인은 highlighter state만 진행시켜 multi-line construct(블록 주석, 문자열 리터럴)의 syntax 연속성을 유지한다. -- **줄 번호 gutter**(`ui/diff_viewer/gutter.rs`): `DiffLine`이 libgit2의 `old_lineno`/`new_lineno`를 그대로 들고 다닌다. 추가 줄은 old가, 삭제 줄은 new가 `None`이라 해당 칼럼을 비운다 — hunk 헤더에서 파생시키지 않는 이유는 kind별 카운터를 렌더 층에서 관리하게 되어 상태가 잘못된 층에 놓이기 때문이다. unified은 두 칼럼, split은 좌=old·우=new 한 칼럼씩, file view는 파일 자신의 번호를 보여준다. - - **gutter와 본문은 반드시 별개 `Paragraph`여야 한다.** diff 계열은 수평 스크롤을 `Paragraph::scroll((0, x))`로 구현하는데 이건 라인을 통째로 밀기 때문에, 같은 paragraph에 있는 gutter는 `scroll_x > 0`이면 왼쪽으로 사라진다(실제로 file view에 그 버그가 있었다). `Block`을 따로 그리고 `block.inner`를 `Layout::Horizontal`로 쪼개 gutter는 `scroll((0,0))`, 본문만 스크롤한다. 수직 스크롤은 **어느 행을 담았는지**로 표현되므로 두 vector를 같은 루프에서 lockstep으로 채우는 것이 정렬을 지키는 유일한 수단이다. - - 폭은 로드된 hunk 전체의 최대 줄 번호에서 파생하고 최소 3자리를 보장한다. 보이는 창 기준으로 계산하면 스크롤 중에 본문 좌측 경계가 흔들린다. hunk 헤더 행도 같은 폭의 빈 gutter를 받아야 `@@`가 본문보다 한 칼럼 왼쪽에서 시작하지 않는다. - - `MIN_SPLIT_WIDTH`를 80 → 90으로 올렸다. 각 half가 gutter에 5칼럼을 쓰므로, 문턱을 그대로 두면 side-by-side 진입은 되지만 half당 읽을 수 있는 코드 폭이 조용히 줄어든다. -- **자동 줄바꿈**(`DiffPane::wrap`, diff pane focus에서 `w`): ratatui `Paragraph::wrap`은 켜지면 `scroll.x`를 무시하므로(`ratatui-widgets`의 `render_paragraph`가 wrap 분기에서 `WordWrapper`만 쓰고 `LineTruncator`의 horizontal offset 경로를 타지 않는다) **줄바꿈과 수평 스크롤은 구조적으로 배타**다. 켤 때 `scroll_x`를 0으로 되돌린다 — 남겨두면 끌 때 낡은 오프셋이 되살아난다. - - 줄바꿈 모드에서는 **gutter를 본문 라인 안으로 접어 넣는다**. 본문 한 줄이 여러 화면 행을 먹는데 gutter 라인은 한 행이라, 두 paragraph를 나란히 두면 그 아래 전부가 어긋난다. gutter를 분리한 애초의 이유(수평 스크롤)가 이 모드엔 없으므로 인라인이 안전하다. 대가는 이어지는 행에 번호가 붙지 않는 것. - - **split 뷰는 줄바꿈을 무시한다.** 좌/우 half가 서로 다른 높이로 접히면 행 대응이 무너지는데, 그 대응이 이 레이아웃의 유일한 존재 이유다. - - 수직 스크롤은 여전히 **논리 줄** 단위다(렌더러가 창을 직접 슬라이스하고 ratatui의 vertical scroll을 쓰지 않는다). 따라서 줄바꿈이 켜진 채 긴 줄이 많으면 pane 높이보다 적은 논리 줄만 보이고 아래가 잘린다 — 스크롤로 전부 도달할 수 있으므로 감춰지는 내용은 없다. 검색 매치가 논리 행 인덱스라는 전제도 이 덕분에 유지된다. -- **표시 방식 전환**: `DiffPaneView`는 `Diff`/`Split`/`File` 세 값인데 `v`(File 토글)와 `s`(Split 토글)는 각각 unified를 기준으로 한 축만 오간다 — 세 번째가 있다는 걸 모르면 발견할 수 없다. `Tab`(`App::cycle_diff_view`)이 `Diff → Split → File → Diff`로 셋을 모두 순회해 집합을 드러내고, `v`/`s`는 아는 뷰로 바로 가는 용도로 남는다. File 단계는 `can_open_file_view`가 거짓이면(선택 없음 / 해석 불가한 커밋 파일) 건너뛴다 — `v`가 no-op이 되는 것과 같은 게이트이며, 순회 중 죽은 입력을 만들지 않기 위함이다. Tree 모드는 우측 pane이 항상 파일 미리보기라 순회 대상이 없어 no-op이다. - -### Split-View Terminal Panel - -The lower panel renders every pane in the current *visible window* at once -instead of switching between tabs. A pane's PTY keeps running in the -background even while scrolled out of the window. - -- **Visible window**: `TerminalState.visible_start`/`active` define a - `[visible_start, visible_start + max_visible)` index range. `max_visible()` - is driven by the `TerminalFullscreen` state: `Off` → `max_visible_normal` - (4), `Grid` → `max_visible_fullscreen` (8), `Zoom` → 1. `TerminalState::sync_visible_window` (backed - by the pure `runtime::terminal::visible_range`) re-clamps this range to - always contain `active`, nudging the window the minimum amount needed - rather than re-centering. It must be called after anything that changes - `active` or the pane count — `create_pane_with`, `switch_pane`, - `swap_active_with`, `cycle_focus_forward/backward`, pane close/exit clamp, - and session restore all do this; adding a new mutation site for `active` - without a matching `sync_visible_window` call is a bug. -- **Pane reorder (swap)**: `TerminalState::swap_active_with(idx)` exchanges the - active pane with the pane at `idx` in the ordered `panes` Vec and sets - `active = idx` so focus follows the moved pane. Only the Vec order changes — - all per-pane state (parsers, scroll, sizes, prompt buffers, backend PTYs) is - keyed by the stable `PaneId`, so a reorder never touches it. Pane order is not - persisted (PTYs are live processes recreated from `startup_commands` on - restart), so swap is session-transient; the saved `active_pane` index stays - consistent because `active` is updated in step. Triggered by ` s`, - which arms a second follow-up state (`App::awaiting_swap_target`, mutually - exclusive with `prefix_armed`); the next digit is resolved through - `resolve_prefix_action` — the same layout-aware mapping as the focus-jump - digits — so both stay in lockstep in split view and fullscreen alike. - Arming shares ` w`'s terminal-focus scope (without it the active - pane — the swap's first operand — is rendered indistinguishable) and - additionally requires a second pane; otherwise the chord is consumed - without arming, and the armed hint row hides `s: swap pane` under the - same conditions. -- **Layout-aware jump keys**: the leader digit row switches mapping by layout. - In the split view `input::prefix_action` maps `1`=list, `2`=diff, - `3`..`9`,`0`=panes `0`..`7`. While the terminal fills the body - (`fills_body()`) the upper viewer is hidden, so `main::resolve_prefix_action` - swaps in `input::prefix_action_fullscreen`, which maps `1`..`8` → panes - `0`..`7` by natural numbering (`9`/`0` dropped, non-jump keys unchanged). No - jump key returns to the list/diff in fullscreen — the sole exit is - ` f`, which cycles fullscreen off. The tab bar (`render_tab_bar`) - mirrors the active mapping in its key legend (` 1`..`8` in - fullscreen, ` 3`..`9`,`0` in split view). - The bare F-key row is a **separate axis**: `F1`..`F10` select project tabs and - are deliberately NOT layout-aware, so one F-key reaches one project in every - view. That is why the pane legends name the leader chord rather than an F-key. -- **Fullscreen cycle**: ` f` while the terminal is focused cycles - `App::toggle_terminal_fullscreen` through `TerminalFullscreen::{Off, Grid, - Zoom}` (`Off → Grid → Zoom → Off`). `Grid` and `Zoom` both hide the top - viewer and hand the whole body to the terminal (`fills_body()`); the - render/`terminal_widget_area` branches key off that. `Zoom` needs no - dedicated render path — it just caps `max_visible()` at 1, so the shared - grid path draws the active pane alone (no border, per the single-pane - case). Because `Grid` and `Zoom` are indistinguishable whenever `Grid` - would show a single pane, the cycle skips `Zoom` in that case — the - predicate `TerminalState::zoom_distinct_from_grid` - (`max_visible_fullscreen.min(panes.len()) > 1`) is the single source of - truth for it, shared by the toggle, the pane-close normalization, and the - hint text. Entering any body-filling state - moves focus to the terminal and clears the competing diff/list fullscreens; - closing the last pane resets to `Off`. Persistence collapses `Zoom` to - `Grid` on save (session stores a single bool). -- **Grid layout**: `ui::terminal_tab::split_pane_areas` lays out 1 pane full - width, 2 side-by-side (or stacked if the area is narrow), 3 as a 2-column - row plus a full-width remainder, 4 as 2x2, 5–6 as 3 columns, 7 as 4-then-3 - rows. The single-pane case takes a dedicated no-border code path so - copying terminal output — bypass-modifier+drag (Shift/Option/Fn by - terminal) while the mouse is captured, plain drag with `[mouse]` disabled - — still never picks up a stray `│`; this is - the overwhelmingly common case and must not regress. -- **Sizing invariant**: `ui::terminal_tab::visible_pane_cells` is the single - source of truth for pane Rects. `render` draws from it every frame, and - `ui::terminal_content_areas` → `main_loop`'s `resize_visible_panes` call - reads from the same function, so a pane's backend PTY + emulator size - always matches exactly what's drawn inside its cell. Don't compute pane - sizes independently in a new call site — route it through this function. -- **Input/scroll scope unchanged**: keyboard input, paste, prompt logging, - and terminal scroll (`TerminalState::active_pane_rows` for page size) - still target only the active pane, even though multiple panes are drawn. -- **Accent means real focus, not just "active pane"**: the accent color is - reserved app-wide for "this region has keyboard focus right now" (see - `focused_border_style`, used identically by `FileList`/`DiffViewer`). The - active pane's cell border/tab only gets accent when `Focus::Terminal` is - also true; otherwise it renders pixel-identical to an inactive pane (plain - `Color::DarkGray`/`Color::Gray`, no bold, no lighter stand-in color) so it - never looks focused while another region actually has focus. - -### Worker Thread Lifecycle (intentional asymmetry) - -백그라운드 worker(`SnapshotChannel`, `CommitLogPagination`, `PtyPane`)는 모두 "receiver/owner를 먼저 drop → worker가 다음 send 실패로 종료"라는 공통 종료 신호를 쓰지만, **호출 지점이 hot path인지 quiescent moment인지에 따라 join 정책이 의도적으로 다르다.** 리뷰 시 이 비대칭을 깨뜨리지 말 것. - -- **Hot path (UI 틱 안)**: `launch_commit_log_worker`는 이전 `JoinHandle`을 join 없이 drop한다. 매 prefetch마다 5ms를 기다리면 스크롤이 jank해진다. worker 본체는 `tx.send` 1회 후 종료하므로 누적되지 않고, 받는 쪽(`page_rx`)을 먼저 drop했기 때문에 그 send는 즉시 실패한다. **timed-join을 여기 추가하지 말 것.** -- **Quiescent moment (Drop, repo switch, reply drain 직후)**: `cancel_commit_log_page_fetch`, `poll_commit_log_page_fetch`의 reply drain 분기, 그리고 `Drop` impl은 모두 `try_timed_join`(~5ms)을 사용한다. 사용자가 클릭한 시점이거나 worker가 이미 마지막 syscall에 도달한 시점이라 잠깐의 대기를 흡수해도 UX 손실이 없고, OS 스레드를 즉시 회수한다. - -`try_timed_join`은 `src/platform/threading.rs`에 공유 helper로 두고, snapshot/commit-log/PTY 세 곳에서 모두 호출한다. 새 worker 패턴을 추가할 때도 같은 분기 기준으로 join 정책을 선택한다. - -### Status filter cache - -`StatusView::filter_cache`는 `search_query` 또는 `files`가 변경될 때만 재계산된다 (`recompute_filter`). 렌더러와 navigation helper는 캐시된 슬라이스를 읽기만 한다. - -### File-Tree Navigator (`ViewMode::Tree`) - -` b`로 진입하는 read-only 디렉토리 트리. 좌측 리스트가 워크트리 전체를 탐색하고, 파일 선택은 기존 file-view pane(`DiffPaneView::File`)을 재사용한다 — 새 렌더 경로를 만들지 않는다. - -- **Lazy one-level reads**: `git::tree::read_children`가 `std::fs::read_dir`로 정확히 한 디렉토리 레벨만 읽는다. 펼치지 않은 서브트리는 절대 walk되지 않는다. `.gitignore` 필터링은 libgit2를 통하고(`[tree] respect_gitignore`), symlink는 non-directory로 보고해 visited-set 없이 순환을 차단한다. -- **Derived rows**: `TreeView`는 per-directory child cache와 expanded set만 저장하고, 보이는 행 리스트는 `visible_rows`로 매번 파생한다 — 확장 상태와 flatten된 뷰가 어긋날 수 없다. 디렉토리 I/O는 전부 `app/tree.rs`(UI 스레드 동기)에 있어 populated cache가 주어지면 `tree_view.rs`는 순수하고, 파일시스템 없이 단위 테스트된다. -- **파일명 검색**: 트리 focus에서 `/`가 검색 오버레이를 열 때 `build_tree_index`가 `max_depth`까지 전체 트리를 한 번 walk해 flat index를 만들고, 이후 필터링은 인메모리다. `Enter`는 선택 경로의 조상 디렉토리를 모두 펼쳐 일반 뷰에서 reveal한다. -- **Live watch**: `runtime::tree_watch`가 notify(+debouncer-mini)로 **펼친 디렉토리만 비재귀로** 감시한다(yazi/broot/nvim-tree와 같은 전략) — 워크트리 전체 재귀 감시는 디렉토리당 inotify watch 하나를 소비해 대형 트리에서 무너진다. `[tree] live_watch = false`면 Tree 진입 시에만 재조회한다. -- **Read-only 보장**: 트리는 어떤 쓰기·이름변경·삭제도 수행하지 않는다. -- **세션 지속성**: expanded set과 선택 경로는 세션에 저장·복원되며, 복원 시 unsafe 경로와 사라진 디렉토리의 stale 확장은 정리된다. - -### Keyboard Routing - -라우팅은 leader(prefix) 모델을 따른다. 1순위 사용자는 패널에서 LLM CLI를 굴리는 cockpit 사용자이므로, `Ctrl+W`/`Ctrl+L` 같은 프롬프트 편집 Ctrl 키가 nightcrow에 가로채이지 않고 PTY로 통과해야 한다. 앱 전역 명령은 leader 뒤에 한 키를 눌러야만 실행된다. - -- **Leader (prefix)**: 기본값 `Ctrl+F`, `[input] leader`로 변경 가능(`config.rs::parse_leader`가 `ctrl+`만 허용하고 예약키·인코딩 불가 chord는 거부). leader를 누르면 `App.prefix_armed` 플래그가 켜지고, 다음 키 한 개가 앱 명령(`input::prefix_action`)으로 해석된다. **타임아웃은 없다** — armed 상태는 follow-up 키나 `Esc`/`Ctrl+C`로만 해제된다. 해제 경로는 셋뿐이다: 매핑된 키 → Action 실행 후 해제, 미매핑 키 → 소비 후 해제, `Esc`/`Ctrl+C` → 취소. ` `는 terminal focus에서 leader를 `encode_key`로 리터럴 PTY 전송한다. prefix 매핑: `t`=NewPane, `w`=ClosePane(terminal focus 한정 — unfocus 시 active pane이 다른 pane과 동일하게 그려져 닫힐 대상이 보이지 않으므로, 키는 소비하되 no-op이고 힌트 바에도 노출하지 않는다), `s`=pane swap 대기 모드 arm(같은 terminal-focus 스코프 + pane 2개 이상 필요 — 상세는 "Split-View Terminal Panel"의 swap 항목), `c`=CancelRecovery(plugin이 대기 중인 pane recovery를 포기 — 대기 중인 것이 있을 때만 힌트에 노출된다), `l`=ToggleLogView, `b`=ToggleTreeView(트리 뷰 ↔ status 뷰), `f`=ToggleFullscreen, `o`=OpenProject(저장소를 새 프로젝트 탭으로 — 제자리 교체 명령은 없다), `x`=CloseProject, `p`=CycleTheme, `r`=Redraw, `q`=Quit. 숫자는 지금 body가 보여주는 것을 지시한다: `1`=FocusList, `2`=FocusDiff, `3`–`9`,`0`=pane 0–7로 focus 이동(`0`은 digit이 9까지뿐이라 8번째 pane을 가리킨다). bare F키는 별개 축이며 프로젝트 탭을 고르므로 이 digit들과 충돌하지 않고, 서로 자리를 비워줄 필요도 없다. pane 포커스 이동은 tab 전환이 아니라 어떤 pane이 active인지만 바꾼다 — split-view grid는 이동 전후로 계속 여러 pane을 동시에 그린다. -- **No-prefix 예약키**: `F1`–`F10`(프로젝트 탭 1–10 전환 — layout에 따라 바뀌지 않는 유일한 점프 축), `Shift+←/→`(focus cycle — terminal focus 상태에서는 active pane을 앞/뒤로 이동), `Shift+↑/↓`·`Shift+PgUp/PgDn`(터미널 스크롤, active pane 기준 — 전달 방식은 "Scroll Routing" 참조)는 leader 없이 항상 앱이 먼저 처리한다. modifier 또는 F-key라서 프롬프트 텍스트와 혼동되지 않는다. -- **Upper panel focused**: leader 명령과 no-prefix 예약키를 제외한 나머지는 로컬 네비게이션(`j`/`k`, `/`, `v`, `n`/`N`, `Enter`, `Esc`, 화살표, `PgUp`/`PgDn`)으로 처리된다. `j`/`k`는 upper-pane handler 내부에서 vim navigation으로 변환되며, `map_key`는 plain character로 통과시켜 terminal focus에서 PTY로 그대로 전달되게 한다. -- **Lower panel focused (terminal)**: leader/예약키가 아닌 모든 키는 active backend의 stdin으로 직접 통과한다(`encode_key`가 화살표/F-key/제어문자를 VT100 시퀀스로 인코딩). 단독 `Ctrl+T/W/L/O/P/Q` 등은 앱 명령이 아니므로 control byte로 PTY에 전달된다(리더 `Ctrl+F`만 prefix를 arm하고 통과하지 않는다). bare F키는 앱이 가로채므로 pane 안 프로그램(htop, mc 등)의 F키 메뉴는 동작하지 않는다 — 수정자를 붙인 `Ctrl+F1`, `Shift+F5` 등은 통과한다. -- overlay(repo input/search) active 시에는 leader dispatch가 금지되고 overlay가 키를 소유한다. armed 중 overlay가 열리는 경로면 prefix를 취소한다. repo 다이얼로그는 `Workspace` 소유라 `main::dispatch_key`가 per-project 핸들러보다 먼저 처리한다 — 프로젝트가 없을 때도 열려야 하기 때문. -- **프로젝트가 없을 때**: `main::handle_empty_key`가 leader arming과 `o`/`q`만 해석하고 나머지는 버린다. ` `는 여기서도 액션 테이블로 넘어가지 않는다 — 기본 leader가 `ctrl+f`라 follow-up이 `f`에 매칭돼 fullscreen이 토글될 수 있기 때문. -- 좌측/우측 패널 타이틀에는 현재 포커스 단축키(` 1` / ` 2`, 기본 leader면 `^F 1` / `^F 2`)가 노출돼 사용자가 즉시 jump 키를 알 수 있다. `ui::jump_legend`가 설정된 leader label과 digit을 **공백으로** 이어 붙인다 — `^F1`로 붙여 쓰면 Ctrl+F1로 읽히고, 그 조합은 앱이 가로채지 않고 PTY로 통과시키는 별개 키라 오해를 만든다. 프로젝트 탭 행이 쓰는 `F1`…`F10` legend와는 다른 축임에 주의한다. - -### Project Boundary (`Workspace` / `App`) - -한 프로세스가 저장소 N개(최대 `MAX_PROJECTS` = 10, F1~F10 키 공간과 일치)를 -탭으로 연다. - -- `App` = 저장소 하나의 상태 전부. 터미널 pane도 `App`에 있으므로 프로젝트마다 - 자기 PTY 집합과 cwd를 갖는다. -- `Workspace` = `Vec` + 활성 인덱스. 탭 전환은 인덱스 변경뿐이며 어떤 - 프로젝트 상태도 건드리지 않는다. 목록은 **비어 있을 수 있다** — 인자 없는 - 실행이 그 상태이고, 마지막 탭을 닫아도 그리로 돌아온다. 그래서 `active()`가 - `Option`이다. - -저장소를 "교체"하는 경로는 없다. 탭을 닫으면 `App`이 drop되면서 -`SnapshotChannel`이 worker를 join하고 `TerminalState`가 자식 프로세스를 -정리하므로, 손으로 유지하는 초기화 목록이 존재하지 않는다. 제자리 교체는 -pane을 살려두는 탓에 탭 라벨과 셸의 작업 디렉토리가 어긋나기도 했다. - -**프로세스 레벨 상태** — 저장소 열기 다이얼로그(`repo_input`)는 `Workspace`에 -있다. 프로젝트가 없을 때도 동작해야 하는데, 그때가 바로 이 다이얼로그가 유일한 -행동이기 때문이다. 그것이 참조하는 leader 화음과 거부된 경로를 알릴 notice -슬롯도 함께 있다. 반면 `handle_key`는 여전히 `&mut App` 하나만 받는다 — -`dispatch_key`가 워크스페이스 레벨 경우(다이얼로그, 빈 화면의 두 키)를 먼저 -해소하므로, 프로젝트별 입력 경로 전체가 프로젝트 하나만 아는 채로 유지된다. - -**경로 완성** — 다이얼로그의 `Tab`은 `workspace/path_complete.rs`가 처리한다. -셸을 PTY로 띄우지 않는 이유와 대안 비교는 `docs/repo-picker-plan.md`에 있다 — -요약하면 Windows에 readline 대응 프리미티브가 없어서 네이티브 완성기가 어차피 -필요하다. 규칙은 무상태 하나다: **확장할 게 있으면 확장하고, 없으면 후보를 -보여준다.** 단 fragment가 비어 있으면(구분자로 끝나는 상태) 확장과 동시에 -목록도 낸다 — 그때의 `Tab`은 "여기 뭐가 있냐"는 질문이라 조용한 확장은 답이 -아니다. Tab 한 번에 `read_dir` 한 단계만 읽고 디렉터리만 후보로 삼는다. - -사용자가 입력한 텍스트는 다시 쓰지 않는다. `~`나 상대 경로는 **읽을 때만** -확장하고 버퍼에는 완성된 컴포넌트만 이어붙인다 — `~/x`를 `/Users/me/x`로 -바꿔 써넣으면 사용자가 타이핑한 적 없는 경로가 화면에 남는다. - -`git::tree::read_children`(`ViewMode::Tree`용)을 쓰지 않는다는 점에 주의한다. -그쪽은 `git2::Repository`가 필수이고 repo-relative 경로만 받으며 워크트리 밖 -경로와 심볼릭 링크를 거부하는데, 피커는 어떤 repo에도 속하지 않는 경로를 -돌아다녀야 하고 프로젝트가 0개일 때도 떠야 한다. 심볼릭 링크 정책도 반대다 — -트리는 링크를 따라가지 않지만(순환 방지) 피커는 따라간다(링크된 체크아웃이 -실제 repo다). - -후보는 notice 행에 표시한다(`ui/notice.rs`). 우선순위는 notice > 후보 > -repo 헤더다. 플로팅 팝업을 쓰지 않은 이유는 `src/ui/`에 오버레이 인프라가 -없고(모든 surface가 레이아웃 행을 차지한다) 마우스 캡처가 기본 on이라 -`hit_test.rs`에 새 히트 영역이 필요해지기 때문이다. - -**디렉터리 브라우저** — `workspace/path_tree.rs`(상태) + `ui/path_tree.rs`(렌더). -경로를 아는 경우(형제 체크아웃 — prefill이 노리는 케이스)는 타이핑이 빠르고 -모르는 경우는 브라우저가 낫다. 둘은 경쟁이 아니라 계층이다. - -- **진입은 `↓`**(또는 `↑`). printable 문자는 전부 합법 경로 문자라 쓸 수 없고, - 필드의 수평 키(`→`/`End`=prefill 수락)는 이미 "이 경로를 편집한다"는 뜻이라 - 수직 축이 비어 있다 — 브라우저 안에서 `↓`/`j`가 커서를 옮기므로 진입 키와 - 진입 후 조작이 같은 축에 놓이고, 모든 자동완성이 목록을 아래에 두는 관용과도 - 맞는다. `Ctrl+T`는 접었다: `T` 니모닉이 ` t`(새 터미널)와 겹쳐 - "충돌하지 않는다"를 설명해야 했고, 다이얼로그의 다른 키가 전부 bare인데 - Ctrl 화음만 튄다. -- **후보 목록이 떠 있을 때의 두 번째 `Tab`도 브라우저로 승격한다.** 그 상태의 - Tab은 같은 목록을 다시 그리는 죽은 키였고, 평면 목록이 실패한 지점이 정확히 - 거기다 — 배울 키 없이 도달하는 경로를 하나 남긴다. -- **`Enter`는 확정이 아니라 필드로 되돌리며 경로를 채운다.** repo를 실제로 여는 - 지점은 필드의 `Enter` 한 곳뿐이다. 그래서 `Enter`의 의미가 두 surface에서 - 갈리고, 브라우저에서는 확장이 `→` 전용이다(트리 뷰도 확장은 `→`/`←` 전용이며 `Enter`는 파일 열기다). -- **평면 row 리스트**로 들고 있다. 확장은 자식을 부모 뒤에 splice, 접기는 아래 - 깊은 row를 drain — 선택이 화면 인덱스 그대로여서 프레임마다 flatten이 없다. -- **사용자 표기를 보존한다**(완성기와 같은 이유). `root_text`(타이핑한 그대로)와 - canonical `PathBuf`를 따로 들고, 고른 경로는 `root_text` 기준으로 조립한다. - `←`가 depth 0에서 루트를 한 단계 올릴 때만 예외가 생긴다 — `~`나 Windows - 드라이브의 부모는 사용자 표기로 표현할 수 없으므로 절대 경로로 대체하되, - 텍스트 수술을 믿지 않고 `canonicalize` 결과를 실제 부모와 대조해 검증한다. -- **body 전체를 쓴다**(위의 팝업 부재와 같은 이유). 다이얼로그가 이미 모든 키를 - 소유하므로 view mode·fullscreen 분기보다 앞에서 body를 가로챈다 — 그 분기들이 - 그릴 것은 어차피 inert다. 마우스 클릭 선택은 범위 밖(`hit_test.rs`에 새 히트 - 영역이 필요하다). 세션 저장도 하지 않는다: 필드가 활성 프로젝트 경로로 - prefill되므로 "지난 위치"가 새 영속 상태 없이 따라온다. -- 브라우저를 열면 `prefilled`가 해제된다. 브라우저는 버퍼에 전체 경로를 쓰므로, - 플래그가 살아 있으면 복귀 후 첫 타이핑이 방금 고른 경로를 지운다. - -다이얼로그는 hint legend를 통째로 대체하므로(입력 줄이 그 자리를 쓴다) 키를 -알릴 다른 자리가 없다. `hint_bar::repo_input_line`이 커서 뒤에 축약 legend를 -붙이고, 폭이 모자라면 잘라내지 않고 통째로 버린다 — 커서는 반드시 보여야 하고 -반쪽 legend는 렌더 결함으로 읽힌다. - -입력 핸들러는 `&mut App` 하나만 받으므로 탭 목록에 닿을 수 없다. 대신 -워크스페이스 수준 의도를 `KeyOutcome::Project(ProjectRequest)`로 반환하고 -`main_loop`이 실행한다. 이 덕분에 프로젝트별 입력 경로 전체가 그대로 유지된다. - -**Polling 규칙** — 모든 프로젝트가 매 tick 자기 큐를 비우지만(스냅샷 worker와 -PTY reader는 unbounded 채널에 계속 쓰므로), 스냅샷을 *적용*하는 것은 활성 -프로젝트뿐이다. 적용은 전체 `refresh_diff`를 돌리므로 열린 저장소마다 -프레임당 git diff를 UI 스레드에서 수행하게 된다. 배경 스냅샷은 -`pending_snapshot`에 대기하다 탭이 앞으로 나온 첫 tick에 적용된다. -**중복 방지** — 다른 탭이 이미 연 저장소는 두 번 열지 않고 그 탭으로 -포커스를 옮긴다. 같은 workdir에 프로젝트 두 개는 스냅샷 worker가 중복으로 -돌고 같은 session 파일에 쓴다. git 저장소가 아닌 경로는 canonicalize해서 -철자 차이(`/w` vs `/w/`)가 이 검사를 빠져나가지 못하게 한다. - -**세션** — 열린 탭 목록, 활성 탭, 저장소별 뷰 상태가 모두 -`~/.nightcrow/workspace.json` 한 파일에 들어간다. 저장소 안에는 아무것도 쓰지 -않는다: 어떤 저장소도 "옆에 다른 셋이 열려 있었다"는 사실을 소유하지 않고, -읽기만 하는 프로젝트에 디렉토리를 만들 이유도 없다. 뷰 상태는 최근 사용한 -50개 저장소까지 LRU로 유지한다. `--repo`가 주어지면 탭 목록은 복원하지 않는다 — -명시적 인자가 이긴다. 빈 목록도 기록한다: 탭을 다 닫고 종료하는 것이 다음 -실행을 빈 화면으로 시작하는 방법이고, 기록을 건너뛰면 이전 탭이 되살아난다. - -**복원 시점** — 세션은 로드 즉시 적용한다. pane/focus/fullscreen은 어떤 -데이터도 필요 없고, Log는 commit log를, Tree는 디렉토리를 직접 읽으므로 -스냅샷을 기다릴 이유가 없다. 유일한 예외가 Status 모드의 파일 선택인데, 이는 -변경 파일 목록이 필요해 `pending_selection`에 대기한다. 이 지연은 사용자 조작과 -충돌할 수 없다 — 빈 목록에서는 선택할 파일이 없기 때문이다. 대기하던 선택은 -별도 복원 단계가 아니라 기존의 "커서를 같은 파일에 유지" 경로를 타고 적용된다. - -**자원 (측정치, 2026-07-20)** — 프로젝트를 여러 개 여는 비용을 실제로 재봤다. -저장소 10개(각 파일 30개, 그중 10개 dirty), 프로젝트당 pane 2개, release 빌드: - -| | 1 프로젝트 | 10 프로젝트 | -|---|---|---| -| 스레드 | 6 | 60 | -| RSS | 38MB | 43MB | -| 자식 프로세스 | 1 | 19 | -| 유휴 CPU | — | 20초에 0.47초 (~2.4%) | - -메모리는 프로젝트당 0.5MB 남짓만 늘어 사실상 문제가 아니고, 10개 저장소를 -동시에 폴링하는 유휴 CPU도 낮다. 탭 전환은 인덱스 변경이라 실측 70ms 수준 -(대부분 렌더링). - -주목할 것은 **스레드가 프로젝트당 6개로 선형 증가**한다는 점이다(snapshot -worker, commit-log fetch, PTY당 reader/wait 쌍). 60개 자체는 문제가 아니지만, -이를 막고 있는 것은 `MAX_PROJECTS`(10)와 pane 상한(8)이다. 상한을 올리자는 -논의가 나오면 이 선형성을 근거로 재검토해야 한다. 위 측정은 pane 2개 기준이라 -최악의 경우(10 × 8)는 재보지 않았다. - -**로그 경로** — 로그 파일은 시작 시 한 번 열리므로 활성 탭을 따라갈 수 없다. -첫 `--repo`를, 그것도 없으면 작업 디렉토리를 고정 기준으로 삼는다. - -### Notice Row - -힌트 바 바로 위 한 행. 평상시에는 `ui::mod::render_repo_header`가 repo 경로(`~/...` 형식으로 home-relative 표기), 현재 브랜치, upstream tracking 상태(`↑N ↓M`)를 노출한다. 브랜치/추적 정보는 snapshot worker가 채워주고, detached HEAD/unborn branch처럼 값이 없으면 해당 칩만 생략한다. 마지막 칩은 plugin이 보고한 pane recovery(state·deadline·attempt·detail)이며, 대기 중인 것이 있을 때만 나타난다 — 자세한 내용은 "Recovery Surface" 참고. - -**알림(`App::notice`)이 올라오면 이 행을 덮는다.** 전용 행을 따로 만들지 않은 이유는 알림이 뜨고 사라질 때마다 body가 한 행씩 줄었다 늘어나면서 **열려 있는 모든 PTY가 리사이즈**되기 때문이다(전체화면 프로그램이 매번 다시 그려진다). 이 행의 내용은 매 프레임 `App`에서 다시 계산되는 ambient 정보라 잠시 덮어도 잃는 것이 없다 — 반대로 아래 hint bar는 사용자가 편집 중인 repo 입력 텍스트를 담고 있어 덮으면 안 된다. - -알림은 `Notice { kind: NoticeKind, text }` 타입이고, **만료는 메시지 문자열이 아니라 kind로 판정한다**. 이전에는 `msg.starts_with("git error:")` 같은 접두사 매칭이라 (a) 사람이 읽는 문구에 해제 로직이 묶여 있었고 (b) 매칭 arm이 없는 종류(`Terminal`/`Tree`/`Session`)는 repo를 바꾸기 전까지 영영 사라지지 않았다. 해제 경로는 둘이다: - -- **같은 kind의 성공** — `App::clear_notice(kind)`. 각 서브시스템의 성공 경로에서 호출하며, 그 사이 도착한 다른 종류의 알림은 건드리지 않는다. -- **앱 레벨 키 입력** — `App::dismiss_notice_on_app_input()`. PTY로 그대로 포워딩되는 키는 **제외**한다. 터미널 패널에서는 모든 키가 passthrough라 포함시키면 사용자가 타이핑을 재개하는 순간 알림이 사라져, 이 행이 막으려던 "보이지 않는 에러"로 되돌아간다. - -hint bar는 오버레이(repo 입력·prefix armed·swap target)가 열리면 그 내용으로 먼저 `return` 하므로, 알림이 거기 있던 시절에는 오버레이가 열린 동안 어떤 에러도 보이지 않았다. 알림을 별도 행으로 분리하면서 이 경합 자체가 사라졌다. - -### Terminal Emulation Layer - -`runtime::emulator::PaneEmulator`가 pane당 하나씩 alacritty_terminal의 `Term` + ANSI `Processor`를 감싸고, 렌더러는 `ScreenView`/`CellView`로만 화면을 조회한다. alacritty 타입은 이 모듈 밖으로 노출되지 않으므로 에뮬레이터 교체·업그레이드의 영향 범위가 이 파일 하나로 국소화된다. - -원래는 vt100 크레이트를 사용했으나 alacritty_terminal 0.26으로 교체했다. 근거: vt100은 (1) 스크롤백 underflow panic(당시 vendor 패치로 우회), (2) 스크롤 offset 초과 panic(앱 레벨 캡으로 우회), (3) wide char(한글 등)가 마지막 컬럼에 걸린 채 화면이 축소되면 이후 ED(erase) 처리에서 index out of bounds panic(upstream issue #28, 미수정 방치)으로 세 차례 크래시를 냈고 업스트림 유지보수가 정체 상태다. alacritty_terminal은 Alacritty/Zed에서 실전 검증된 활발한 프로젝트로 리사이즈 시 reflow까지 지원한다. 대안으로 검토한 avt(asciinema)는 바이트 입력·OSC 타이틀 통지가 없고, tui-term/shpool_vt100은 내부가 vt100이라 같은 버그를 공유해 제외했다. 단, alacritty의 최소 그리드는 1행 x 2열(`MIN_COLUMNS`)이라 `PaneEmulator`가 요청 크기를 이 최소값으로 클램프한다 — 1열 그리드는 wide char reflow가 무한 루프에 빠진다. - -**OSC title capture**: `Term`이 OSC 0/2 타이틀을 `Event::Title`로 통지하면 `PaneEmulator::process`가 이를 수집해 반환하고, `TerminalState::poll`이 `PaneInfo.title`에 반영해 탭 바에서 노출한다. claude/vim/ssh 같은 자체 타이틀 갱신 프로그램은 자동으로 적절한 라벨이 붙고, 타이틀을 보내지 않는 셸은 기본 라벨을 유지한다. - -**Terminal query replies**: DSR/DA처럼 내부 프로그램이 터미널에 묻는 쿼리에 대해 에뮬레이터가 생성한 응답(`Event::PtyWrite`)을 `TerminalState::poll`이 해당 pane의 PTY로 되돌려준다. vt100 시절에는 응답이 불가능해 쿼리가 무시됐다. - -### Scroll Routing - -터미널 스크롤 키(`Shift+↑/↓`, `Shift+PgUp/PgDn`)는 항상 에뮬레이터 스크롤백을 움직이는 게 아니라, **pane 안의 프로그램이 기대하는 입력으로 변환**되어 전달된다. 자기 뷰포트를 직접 소유하는 프로그램은 트랜스크립트를 에뮬레이터 그리드가 아니라 자기 메모리에 두므로, 그리드를 스크롤해도 드러날 내용이 없기 때문이다. 특히 alacritty는 alternate screen 그리드를 스크롤백 0으로 생성한다(`Grid::new(lines, cols, 0)`). - -어디로 보낼지는 프로그램이 스스로 켠 모드가 알려준다. `PaneEmulator::scroll_sink()`가 판정하고 `TerminalState::scroll_active`가 실행한다. - -| `ScrollSink` | 조건 | 전달할 입력 | 해당 프로그램 | -|---|---|---|---| -| `MouseWheel` | `MOUSE_MODE` + `SGR_MOUSE` | SGR(1006) 휠 리포트 | Claude Code, `less --mouse` | -| `ArrowKeys` | `ALT_SCREEN` + `ALTERNATE_SCROLL` | 방향키 (xterm alternateScroll) | `less`, `man` | -| `Scrollback` | 그 외 (기본값) | 없음 — 에뮬레이터 뷰를 스크롤 | bash, zsh | - -우선순위는 xterm과 같다. 휠을 요청한 프로그램은 alternate screen에서도 휠을 받는다. `MOUSE_MODE`만 있고 `SGR_MOUSE`가 없으면 legacy X10 인코딩을 기대하는 것인데, 223열을 넘기지 못하는 그 인코딩을 위해 두 번째 인코더를 두는 대신 `Scrollback`으로 떨어뜨린다. - -`Scrollback`이 기본값이어야 하는 이유는 안전 문제다. bash/zsh는 바인딩되지 않은 이스케이프 시퀀스를 받으면 BEL을 울리고 `;2A` 같은 잔여 문자를 프롬프트에 그대로 삽입한다. 따라서 스크롤을 청구하지 않은 pane에는 **한 바이트도 보내지 않는다**. - -합성한 입력은 `send_input`이 아니라 `write_pty`로 나간다. 사용자가 누른 키가 아니므로 스크롤 위치를 초기화하거나 prompt log에 남으면 안 된다 — 에뮬레이터의 쿼리 응답이 `send_input`을 우회하는 것과 같은 이유다. - -### Mouse Routing - -`[mouse] enabled`(기본 on)일 때 crossterm `EnableMouseCapture`로 마우스를 캡처한다. 캡처는 화면 전체 단위라 pane별로 쪼갤 수 없으므로, 바깥 터미널의 네이티브 텍스트 선택은 modifier+드래그 오버라이드로 우회한다(bypass modifier는 터미널마다 다르다 — xterm 계열은 Shift, iTerm2는 Option, macOS Terminal.app은 Fn/Option). 끄면 마우스는 바깥 터미널 소유로 돌아간다(맨 드래그 선택, 클릭 포워딩 없음). - -캡처된 이벤트는 `main::handle_mouse`가 `ui::pane_at`으로 hit-test한다. `pane_at`은 렌더링과 동일한 `terminal_content_areas` 기하를 재사용하므로 화면과 판정이 어긋날 수 없다. pane content 셀 밖(상단 패널, 보더, 탭 바)에 떨어진 이벤트는 버린다. - -- **상단 패널 클릭**: pane content 밖의 press는 `ui::upper_panel_at`(draw와 동일한 split 기하)으로 다시 판정해, 리스트/diff 영역이면 focus만 옮긴다(F1/F2와 동일). fullscreen 상태에서는 판정하지 않는다 — body를 채운 패널이 이미 focus를 갖고 있다. -- **클릭**: press가 클릭된 pane을 활성화하고 focus를 터미널로 옮긴다 — jump key와 동일. press/release는 `TerminalState::click_pane`이 pane-local 1-based 좌표의 SGR(1006) 버튼 리포트로 변환하되, `PaneEmulator::wants_mouse_buttons`(`MOUSE_MODE`+`SGR_MOUSE`)를 켠 프로그램에만 보낸다. Scroll Routing과 같은 침묵 규칙이다: 청구하지 않은 pane에는 한 바이트도 보내지 않는다. 클릭은 스크롤과 달리 스크롤백 폴백이 없으므로, 미청구 클릭은 조용히 버려진다. -- **release 짝짓기**: release는 포인터 아래 pane이 아니라 **press를 받은 pane**으로 간다(`App::pending_mouse_press`, single slot). 드래그 리포트를 포워딩하지 않으므로 프로그램은 포인터 이탈을 스스로 알 수 없다 — press를 본 프로그램은 release도 봐야 하고, 포인터가 우연히 머문 pane이 press 없는 release를 받아서는 안 된다. release 좌표는 press pane의 현재 rect로 클램프하고, 그 pane이 닫혔거나 숨겨졌으면 release를 버린다. -- **휠**: 활성 pane이 아니라 **포인터 아래 pane**을 `scroll_pane`으로 스크롤한다. sink 판정은 Scroll Routing 표와 동일하되, `MouseWheel` sink의 리포트 좌표는 실제 포인터 셀을 그대로 전달한다(키보드 스크롤만 pane 중앙 폴백 — 포인터가 없으므로). 비활성 pane의 `Scrollback` sink에는 per-frame `sync_scroll`(활성 pane 전용)이 닿지 않으므로, `scroll_pane`이 오프셋을 즉시 직접 적용한다. -- **탭 바 클릭**: pane content 밖 press는 탭 바도 판정한다(`ui::tab_click_at` → `terminal_tab::tab_target_at`). 탭/`+N` 마커 세그먼트와 클릭 타겟은 렌더러와 공유하는 `tab_segments` 빌더가 단일 소스다. 탭 클릭은 해당 pane으로의 jump key와 동일하게 `switch_pane`을 타고, `+N` hidden 마커는 그쪽 방향의 가장 가까운 hidden pane으로 점프해 `sync_visible_window`가 창을 한 칸만 슬라이드한다. -- **힌트 바 클릭**: 최하단 행의 press는 `ui::hint_click_at`이 렌더러와 동일한 힌트 텍스트(`normal_hint_literal`/`prefix_armed_hint_text` 공유)를 display width로 세그먼트화해 판정한다. 이산 명령(` t/w/f/l/b/o`, armed row의 follow-up, `v`/`s`/`/`)만 클릭 가능하고, 연속 내비게이션·digit legend·`esc`는 비클릭이다. bare `: leader` 라벨도 클릭 가능하며 leader chord keypress를 합성해 프리픽스를 arm한다 — armed row의 follow-up이 다시 클릭 가능하므로 "leader 클릭 → 명령 클릭"의 마우스-only 플로우가 이어진다. **`q: quit`은 오클릭 한 번으로 세션이 끝나지 않도록 의도적으로 제외**했다. 디스패치는 라벨이 가리키는 키 입력을 그대로 합성해 `handle_key`로 보낸다 — 클릭과 실제 키가 모든 가드(오버레이·프리픽스·포커스 라우팅)와 코드 경로를 공유하므로, 클릭이 키와 다른 동작을 할 수 없다. `r: redraw`의 `KeyOutcome` 전파를 위해 `handle_mouse`도 `KeyOutcome`을 반환한다. 클릭 가능한 세그먼트는 `hint_spans`가 `key: description` 라벨 전체를 REVERSED(배경/글자 반전)로 렌더링해 어포던스를 표시한다 — 반전 범위가 실제 클릭 영역과 일치한다 — 판정을 `segment_click`과 공유하므로 반전된 라벨과 hit-test가 어긋날 수 없고, 스타일만 바꾸므로 컬럼 오프셋은 동일하다. `[mouse] enabled = false`면 클릭이 도달할 수 없으므로 반전도 꺼진다(`App::mouse_enabled`). -- **swap 모드 클릭**: ` s`로 swap 대기 중의 좌클릭은 digit follow-up과 동일하게 **swap 대상 지명**으로 해석한다 — pane 또는 그 탭을 클릭하면 활성 pane과 교환하고, pane을 지명하지 않는 press는 consume+disarm(비-digit 키와 같은 규칙). 이 분기가 없으면 클릭이 swap 상태를 방치한 채 활성 pane만 바꿔 다음 digit이 엉뚱한 pane을 교환한다. -- **드래그/모션**: 포워딩하지 않는다. 내부 프로그램의 자체 텍스트 선택(예: Claude Code의 드래그 선택)은 지원 범위 밖이고, 텍스트 선택은 바깥 터미널의 bypass modifier+드래그(터미널별 Shift/Option/Fn)가 담당한다. - -합성 버튼 리포트도 스크롤과 같은 이유로 `send_input`이 아니라 `write_pty`로 나간다. - -### HEAD Change Detection - -snapshot worker는 매 폴 사이클마다 현재 HEAD oid를 함께 보고한다. UI 스레드는 `poll_snapshot`에서 oid 변동을 감지하면 `refresh_commit_log_after_head_change`로 commit log와 drill-down 상태를 동일 oid 기준으로 재정렬해, 터미널에서 새 커밋·amend·force-push·브랜치 전환이 일어났을 때도 로그 뷰가 즉시 따라잡는다. - -### Commit Log Decoration - -`git log --decorate`가 주는 방향 감각을 로그 뷰에 옮긴 것이다. `src/git/diff/refs.rs`가 -`repo.references()`를 한 번 걸어 `Oid -> Vec` 맵을 만들고, HEAD·로컬 브랜치·태그· -원격 브랜치를 구분해 커밋 행에 chip으로 그린다. 비용은 커밋 수가 아니라 **ref 수**에 -비례하고, annotated tag은 `peel_to_commit`으로 가리키는 커밋에 붙인다. - -- **재생성 시점은 refs fingerprint가 정한다**: fetch가 `origin/dev`를 옮기면 HEAD는 - 그대로여도 chip은 달라져야 한다. snapshot worker가 매 폴마다 ref 이름·타깃의 다이제스트를 - `RepoSnapshot::refs_fingerprint`로 실어 보내고, UI 스레드는 그 값이 바뀔 때만 맵을 다시 - 만든다. 재생성 실패는 이전 맵을 유지한다 — 일시적 읽기 오류로 chip이 사라지는 것보다 - 낫다. -- **ahead/behind는 위치가 아니라 oid 집합으로 판정한다**: 이전 구현은 "위에서 N개가 - ahead"라는 위치 가정이었고, anchor가 HEAD가 아니거나 필터가 걸리면 마커가 엉뚱한 행에 - 붙었다. 지금은 `revwalk.push(local)` + `hide(upstream)`(과 그 반대)로 각 방향의 oid - 집합을 만들어 멤버십으로 판정한다. 집합은 방향당 `MAX_DIVERGENCE_OIDS`개로 끊는다 — - walk가 최신순이므로 잘리는 쪽은 화면에 닿지 않는 꼬리다. -- **1 커밋 = 1 행을 유지한다**: `log_view.selected`가 커밋 인덱스이자 화면 위치라는 전제를 - 선택·스크롤·tail prefetch가 공유한다. 여유 공간은 행이 아니라 **컬럼**으로 쓴다. - `area.width >= MIN_DETAIL_WIDTH`이면 상대 시각 대신 절대 시각, author에 email, short_id - 10자, chip 무절단으로 넓힌다. 판정 기준이 `list_fullscreen` 플래그가 아니라 폭인 이유는 - 넓은 모니터에서는 fullscreen이 아니어도 자리가 남기 때문이고, 이는 - `diff_viewer::MIN_SPLIT_WIDTH`가 이미 세운 선례와 같은 모양이다. -- **commit graph는 범위 밖이다**: lane graph는 topological 정렬을 전제하는데 현재 revwalk에는 - `set_sorting`이 없고, 정렬을 바꾸면 위의 anchor+skip 페이지네이션 계약까지 함께 다시 - 설계해야 한다. - -### Plugin Host (`src/plugin/`, `plugins/`) - -어떤 CLI가 사용량 한도에 걸렸는지 알아보고 한도가 풀린 뒤 세션을 재개하는 일은 provider를 -아는 동작이다. `## Overview`가 못박은 대로 코어는 그런 ontology를 갖지 않으므로, 그 지식은 -**별도 프로세스로 분리한다**. 코어에는 provider를 모르는 host만 두고, Claude Code / Codex / -OpenCode를 아는 코드는 `plugins/nightcrow-recovery`에 산다. 코어 `src/plugin/` 어디에도 그 -세 이름은 나오지 않으며, 그것이 이 경계가 지켜지고 있다는 검사 가능한 조건이다. - -**이 기능은 provider의 한도를 우회하지 않는다.** 하는 일은 사람이 손으로 하던 것 — -한도가 풀릴 시각까지 기다렸다가 같은 세션을 다시 열는 것 — 을 대신하는 것뿐이다. -한도를 늘리거나 회피하거나 감지를 피하는 경로는 없고, 있어서도 안 된다. - -- **왜 자식 프로세스 + NDJSON인가**: Rust에는 안정 ABI가 없어 `libloading` 기반 dylib plugin은 - 버전이 어긋나는 순간 UB다. cargo feature 게이트는 재컴파일을 요구하므로 "설치·제거 가능"이 - 아니다. 남는 것은 프로세스 경계이고, 그 편이 신뢰 모델도 정직하다 — plugin은 우리 주소 공간에 - 없다. 프레이밍은 stdin/stdout의 개행 구분 JSON이고 버전(`v`)이 맞지 않는 줄은 거부한다. -- **도달 범위의 기본은 opt-in, 확장은 증거로만**: plugin은 `[[startup_command]]`이 - `plugin = "이름"`으로 지목한 pane을 본다. 여기에 `[[plugin]]`의 `watch_on_signal`(기본 - `false`)을 켜면 두 번째 경로가 열린다 — **pane 자신의 토큰을 제시한 요청**, 즉 - `PluginCommand::WatchPane { token }`이다. 토큰은 spawn 시각에 그 pane의 자식 환경에만 들어가고 - (`pty_spawn.rs`, 명령 없이 연 pane도 예외 없이) 자식들이 상속하므로, 토큰을 말할 수 있는 것은 - 그 pane 안에서 도는 프로세스뿐이다. 근거가 열거가 아니라 증명이라는 것이 핵심이다: plugin에게 - pane 목록을 주는 경로는 여전히 없고, 맨 셸은 어떤 provider helper도 띄우지 않으므로 영원히 - 채택되지 않는다. "임의의 셸 pane이 자동으로 조작되는 일은 없다"는 성질은 pane을 숨기는 것이 - 아니라 이 증명 요구로 유지된다. `[[plugin]]`은 `enabled = false`가 기본이다. -- **왜 그 확장이 필요했나**: 실제로 압도적으로 흔한 사용은 ` t`로 셸을 열고 `claude`를 - 손으로 치는 것이다. 그 pane은 `create_pane_with(None, None)`으로 열려 launch command가 없고, - `detect(None)`은 어떤 provider도 붙이지 못한다 — 그래서 이 경우 recovery는 **아무것도** 하지 - 않았다. `WatchPane`은 그 구멍만 메운다. `PROTOCOL_VERSION`은 그래서 2가 되었고, 이 명령은 - `generation`을 싣지 않는다: 들어본 적 없는 pane에 대해 어느 spawn인지 정직하게 주장할 수 - 없으므로, 답으로 오는 `PaneOpened`가 그것을 말한다. `Plugins::start`도 그래서 조건이 둘이다 — - enabled이고 **(opt-in됐거나 `watch_on_signal`이거나)**. 후자의 pane은 앞으로 말을 걸어올 - pane이므로 host가 그보다 먼저 떠 있어야 하고, 오지 않을 opt-in을 기다리면 스위치가 아무 뜻도 - 갖지 못한다. -- **요청은 plugin 쪽에서 먼저 줄인다**(`runloop_adopt.rs`): 거부는 응답이 없는 것과 구별되지 - 않으므로, 답을 못 받은 요청이 타이트 루프가 되거나 낯선 토큰마다 상태를 남기면 안 된다. - 미해결 요청은 `MAX_PENDING`개까지만 들고(초과분은 새 것을 버려 실패를 닫힌 방향으로 낸다), - 같은 토큰은 `REQUEST_COOLDOWN` 동안 다시 묻지 않는다 — Claude Code의 statusline은 매 렌더마다 - 돌기 때문에, 이것이 없으면 남의 pane 하나가 초당 몇 번씩 명령을 써서 host의 tick당 예산을 - 정작 필요한 요청과 함께 태운다. 그리고 요청을 정당화한 **신호는 버리지 않고 들고 있다가 - `PaneOpened` 뒤에 재생한다**: 신호가 pane보다 먼저 도착하고(그 신호가 pane이 도착한 이유다) - host는 새로 넘긴 pane에 어떤 history도 재생해 주지 않으므로, 버리면 지금 복구해야 할 그 한도가 - 사라져 provider가 다시 실패할 때까지 pane이 방치된다. 이때 provider는 명령줄이 아니라 - `detect_from_signal`이 고른다 — `SignalKind`는 정확히 한 adapter의 helper만 발행하므로 신호 - 종류 자체가 무엇이 돌고 있는지에 대한 증거이고, 그래서 두 번째 sniffing 경로가 아니라 wire - kind에 대한 lookup이다. -- **늦게 채택된 pane은 relaunch되지 않는다**: launch command가 `None`이므로 프로세스를 되돌려 - 놓으면 provider가 아니라 셸이 다시 뜬다. guard는 이것을 `Refused::NoLaunchCommand`로 — - 인자 문제와 구별되는 자기 이유로 — 거부하고, `allowed_resume_flags`를 어떻게 열어도 통과하지 - 않는다. hub도 같은 판단을 한다: watched pane이 종료했을 때 `is_relaunchable`이 거짓이면 - `PENDING_RELAUNCH_TTL` 동안 slot을 붙잡는 대신 곧바로 닫는 경로를 탄다 — 되돌릴 것이 없는 - slot을 9일 붙잡을 이유가 없다. 이런 pane이 받을 수 있는 recovery는 살아 있는 프로세스에 - 타이핑하는 것 하나뿐이고, plugin 쪽도 같은 결론을 미리 내려 `NeedsAttention`으로 간다 - (`state_resume.rs`). -- **신뢰 경계는 `guard.rs` 하나다**: `protocol::decode_command`는 모양과 크기만 본다. 권한은 - `Guard::judge`만 판단하고, plugin이 우회할 경로가 없다. 규칙: pane이 존재하고 opt-in했는가, - `generation`이 현재와 같은가(이것이 교체된 프로세스에 대한 결정이 후임에게 닿는 것을 막는다), - 살아 있고 조용할 때만 입력을 넣는가, 죽었을 때만 relaunch하는가, 되돌릴 명령이 있는가, - 제어문자가 섞이지 않았는가, slot당 횟수 상한 안인가. 거부는 로그로 남고 재시도되지 않는다. -- **pane을 얻는 규칙만 따로 산다**(`guard_watch.rs`): 나머지 규칙이 모두 "이미 배정된 pane"에서 - 출발하는 데 반해 이것은 배정 자체를 만드는 유일한 자리라, 큰 판단 안의 분기가 아니라 조건 - 목록 하나로 읽히게 분리했다. 순서대로 — 토큰이 아는 pane인가, `watch_on_signal`이 켜졌는가, - 다른 plugin이 이미 보고 있지 않은가(pane 하나에 watcher 하나. 둘이 같은 키보드를 몰면 서로가 - 바꾸는 상태 위에서 recovery가 섞인다), 프로세스가 살아 있는가. 예산은 청구하지 않는다 — - pane을 받는 것은 pane에 하는 일이 아니고, 이어질 행위는 각각 청구되므로 여기서 세면 곧 쓸 - allowance를 미리 태우게 된다. 이미 자기 것인 pane을 다시 물으면 **거부가 아니라 승인**이다: - 명령줄로는 안에 있는 것을 알아볼 수 없었던 opt-in pane이 다시 시도할 유일한 방법이 - `PaneOpened`를 한 번 더 받는 것이기 때문이다. 알 수 없는 토큰이 압도적 다수라는 것도 이 - 설계의 전제다 — 같은 사용자의 다른 nightcrow 세션 pane들이 같은 소켓에 닿는다. -- **`PaneToken`이 정체성인 이유**: `PaneId`는 backend별 카운터라 backend가 다시 만들어지면 1로 - 돌아간다. 프로세스 밖에서 pane을 가리키기에 부적합하고, cwd도 답이 못 된다 — 한 저장소에 - 여러 pane을 두는 것이 지원되는 레이아웃이다. 그래서 난수 토큰을 spawn 시각에 자식 환경 - (`NIGHTCROW_PANE_TOKEN`)으로 넣는다. provider가 띄우는 hook/statusline 자식들이 이를 상속하므로, - plugin은 어떤 pane에서 온 사건인지 추측 없이 안다. -- **provider의 설정 파일은 병합만 한다**(`hooks.rs` / `hooks_merge.rs`): `~/.claude/settings.json`은 - 사용자 것이고 우리가 모르는 키와 hook event를 담고 있을 수 있으므로, 모든 수정은 우리가 넣지 - 않은 것을 보존하는 병합이고, 파일을 이해할 수 없으면(JSON이 아니거나 top-level이 object가 - 아니면) 추측하는 대신 멈춘다. 쓰기는 같은 디렉터리의 temp file → rename이고 모드 `0600`은 - rename 전에 건다(대상이 잠깐이라도 world-readable이 되지 않도록), 첫 쓰기 전에 `.bak`을 - 남긴다. 등록하는 hook event는 정확히 하나다 — `HOOK_EVENT = "StopFailure"`, - `HOOK_MATCHER = "rate_limit"` 아래 - `{"type":"command","command":" hook","timeout":5}`. 최소 권한이라서 그렇다: - `authentication_failed`·`billing_error` 같은 무관한 실패의 payload는 이 프로세스에 아예 - 도달하지 않고, 그 대가로 일시적 `overloaded`/`server_error`는 pane 출력에서 알아본다. - `statusLine`도 같은 자리에서 등록한다. 우리 엔트리를 알아보는 표시는 `command` 문자열에 - `MARKER`가 들어 있는지 하나뿐이다 — provider의 스키마에서 자유 텍스트를 넣을 수 있는 필드가 - 거기뿐이고, 우리 마음대로 만든 키는 provider가 unknown으로 거부하거나 경고할 수 있다. 그래서 - install은 `current_exe()`로 해석한 절대 경로가 `MARKER`를 담지 않으면 **거부한다**: 나중에 - uninstall이 자기 엔트리를 알아볼 수 없게 되기 때문이다. 경로를 `argv[0]`이 아니라 해석해서 - 쓰는 이유는 그 파일을 읽는 것이 작업 디렉터리가 다른 다른 프로세스라는 것이다. -- **helper는 provider의 임계 경로에 있으므로 최소한만 한다**(`helper.rs`): 등록되는 명령은 이 - plugin의 바이너리를 내부 서브커맨드로 다시 부르는 것(`main.rs`의 `Mode::Hook` / - `Mode::Statusline`)이다. `hook()`은 stdin을 상한까지만 읽고 - `["session_id","error_type","hook_event_name"]`만 통과시킨다 — whitelisting이 프라이버시 - 경계다. `StopFailure` payload는 transcript 파일 경로와 provider의 에러 산문을 담으므로, - 상태 기계가 실제로 읽는 필드만 소켓을 건넌다. pane은 `NIGHTCROW_PANE_TOKEN`에서 읽고, 한 줄을 - unix socket으로 보내고 끝난다. 어느 실패도 호출자에게 보고하지 않는다 — 돌지 않는 recovery - plugin은 설치되지 않은 것과 정확히 같아 보여야 한다. -- **IPC 랑데부는 경로 규칙 하나다**(`ipc.rs`): `$XDG_RUNTIME_DIR/nightcrow/recovery.sock`, - 없으면 `~/.nightcrow/run/recovery.sock`. 디렉터리는 `0700`, 소켓은 `0600`이고 bind마다 다시 - 건다. 남아 있는 소켓 파일은 **아무도 듣고 있지 않을 때만** unlink한다(살아 있는 listener를 - 가로채지 않기 위해). `parse_line`은 줄 크기, JSON object 여부, `v` 일치, 토큰의 문자 집합과 - 길이, 아는 `kind`, object payload를 모두 검사하고 실패마다 무엇이 틀렸는지 말한다 — - 여기가 untrusted input이 상태가 되는 경계이므로 조용히 강제 변환하는 필드가 곧 버그다. - **토큰은 correlation key이고 authorisation이 아니다**: 소켓에 닿을 수 있는 것은 아무 pane이나 - 주장할 수 있고, 위조된 메시지가 할 수 있는 최대는 이 plugin이 host에게 무언가를 묻게 만드는 - 것이며 그것은 guard가 처음부터 다시 판단한다. -- **statusline은 가로채지 않고 이어붙인다**(`helper_statusline.rs` / `helper_delegate.rs`): - `statusLine`은 목록이 아니라 명령 하나라 install은 사용자 것을 반드시 밀어낸다. 예전에는 - 거기서 끝나 사용자가 자기 statusline을 잃었다. 지금은 `helper::statusline()`이 pass-through다 — - stdin 바이트를 **그대로** 보관하고, 사본만 파싱해 `rate_limits`를 IPC로 넘기고, install이 - sidecar에 기록해 둔 밀려난 명령을 그 원본 바이트를 stdin으로 주어 실행한 뒤 그 stdout을 - 출력한다. 재직렬화하지 않는 이유는 키 순서와 숫자 표기가 provider의 것이고, 우리가 생기기 - 전부터 그 입력을 읽던 명령이 재배열된 것을 보면 안 되기 때문이다. 실행은 `sh -c`로 한다 — - Claude Code가 `statusLine` 명령은 셸에서 돈다고 문서화하고 자기 예시가 `~`, `jq` 파이프, - 인라인 `$(...)`에 의존하므로 우리가 argv로 쪼개면 사용자가 쓴 뜻이 조용히 바뀐다. `$SHELL`이 - 아니라 `sh`인 것은 대화형 셸이면 refresh마다 rc 파일을 읽기 때문이다. 예산은 2초이고 넘기면 - 죽이고 우리 줄로 떨어진다 — provider는 statusline에 timeout을 문서화하지 않았지만 300ms로 - debounce하고 다음 갱신이 오면 진행 중 스크립트를 취소하므로, 이 상한은 반대 방향(끝나지 않는 - 명령이 이 프로세스를 불멸로 만들지 않게)을 위한 것이다. stderr는 버린다(로그용 경고가 - statusline으로 렌더링되면 안 된다). sidecar에 든 것이 우리 자신의 바이너리면 다시 실행하지 - 않는다(install/uninstall이 쓰는 `is_ours`를 그대로 재사용하므로, 거부되는 것이 그 둘이 자기 - 것으로 아는 것과 정확히 같다). 모든 실패 경로는 plugin 자신의 줄로 격하된다 — 에러를 띄우는 - statusline은 평범한 statusline보다 나쁘다. 비자명한 함정 하나: 밀어낼 `statusLine`이 애초에 - 없었으면 `merge_into`가 `Some(Value::Null)`을 돌려주므로 **sidecar가 `null`을 담을 수 있다**. - 없음(sidecar 없음/읽기 실패)만이 빈 경우가 아니고, `null`도 "실행할 것이 없다"로 읽어야 한다. -- **횟수 상한은 slot(토큰) 기준으로 센다**: relaunch는 반드시 새 `PaneId`를 만든다. 상한을 id로 - 세면 relaunch마다 예산이 새로 생겨서, 즉시 끝나는 명령과 매 종료마다 relaunch하는 plugin이 - 만나면 상한에 영원히 닿지 않는다. 토큰은 relaunch를 건너 살아남는 유일한 값이라 상한이 - 붙어야 하는 곳이다. -- **relaunch는 같은 id를 되살리지 않는다**: id는 단조 증가하고 모든 클라이언트가 `Exited`를 - 그 id의 종결로 취급한다. 그래서 교체는 새 id로 태어나되 토큰을 물려받고 generation이 오른다. - 레이아웃은 새 pane을 원래 인덱스에 넣고 기존 `Reordered`를 브로드캐스트해 보존한다 — 와이어 - 포맷에 relaunch 전용 메시지를 추가하지 않는다. -- **프로세스 해제와 slot 폐기를 분리한다**: 한도 대기는 몇 시간일 수 있다. 죽은 자식의 fd와 - 스레드를 그 시간 내내 붙잡고 토큰만 보존하는 것은 낭비이므로, `release_process`는 PTY를 놓고 - slot만 남긴다. 아무도 relaunch하지 않으면 `PENDING_RELAUNCH_TTL`에 slot을 폐기한다. -- **권한 인자는 사용자가 선언한다**: relaunch가 덧붙일 수 있는 플래그는 `[[plugin]]`의 - `allowed_resume_flags`뿐이고 기본은 빈 목록이다. 코어가 특정 CLI의 위험 플래그 이름을 - 하드코딩하는 대안은 곧 코어가 provider를 아는 것이라 택하지 않았다. 인자는 셸 메타문자를 - 거부한 뒤 개별로 quote되며, 원래 명령 문자열은 수정되지 않는다(다음 relaunch가 인자를 - 누적하지 않도록 보존되는 것도 원래 명령이다). -- **관측 부담을 지지 않는 쪽으로**: 출력 텍스트는 chunk 단위로 escape를 벗겨 넘기므로 두 read에 - 걸친 escape는 완전히 제거되지 않는다. 이것이 허용되는 이유는 출력 텍스트가 언제나 fallback - 신호일 뿐이라는 것이다 — Claude는 hook과 statusline, Codex는 rollout JSONL, OpenCode는 로컬 - 서버의 세션 상태가 1차 신호다. -- **신호의 역할은 분리돼 있고, 이것이 하중을 받는 사실이다**(`provider/claude.rs`): 한도를 - **선언**할 수 있는 것은 `StopFailure`(`on_stop_failure`)와 출력 fallback뿐이다. statusline은 - 정확한 reset epoch만 공급하고 결코 선언하지 않는다 — `on_rate_limits`는 `resets_at`만 기억하고 - `used_percentage`는 100이어도 의도적으로 무시한다(꽉 찬 창은 한도를 뒷받침하지만 선언하지는 - 않는다). 여러 창이 보고되면 가장 이른 것이 유용한 deadline이다. 이 분리의 결과가 - `state_clock.rs`의 `arm_wait`에서 갈린다: `LimitKind::UsageLimit`이고 `resets_at`이 알려져 - 있으면 `WaitingForReset`으로 **정확히 한 번** 기다리고 resume attempt를 쓰지 않는다(아직 아무 - 것도 시도하지 않았으므로). 모르면 `arm_backoff`로 떨어지고, 그쪽은 attempt 예산에 묶인 - 재시도 루프라 `MAX_RESUME_ATTEMPTS`에 닿으면 `NeedsAttention`으로 끝난다. 그래서 hook과 - statusline을 둘 다 설치하는 것의 실질적 이득은 "감지"가 아니라 **기다림이 정확해지고 예산을 - 쓰지 않는다**는 것이다. -- **OpenCode에는 개입하지 않는다**: 자체 재시도가 상한 없이 계속되므로 "재시도 소진"을 기다리는 - 설계가 성립하지 않는다. 프로세스가 끝났거나 상태가 `idle`로 바뀐 뒤에만 손을 댄다. -- **와이어 계약이 두 벌 있다**: plugin은 독립 빌드라 `plugins/nightcrow-recovery`가 프로토콜 - 타입을 따로 갖는다. `PROTOCOL_VERSION`을 진짜 주장으로 만들려면 그래야 하고, 양쪽 모두 JSON - 모양을 리터럴로 고정한 테스트가 있어 드리프트는 테스트 실패로 나타난다. - -#### Recovery Surface (사람이 보고 취소하는 쪽) - -plugin의 `status` 보고는 `ServerMessage::Recovery { pane, state, detail?, deadline_epoch?, -attempt }`로 모든 클라이언트에 브로드캐스트되고, 사람은 `ClientMessage::CancelRecovery { pane }`로 -되돌려 준다. 설계 결정은 다음과 같다. - -- **hub는 보고를 보관하지 않는다**: 도착한 그대로 브로드캐스트하고 잊는다. hub가 소유하는 것은 - hold(exited pane의 slot)뿐이고, 사람이 빼앗을 수 있는 것도 그것뿐이다. 따라서 표시 상태는 - 클라이언트가 최신 보고를 들고 있는 것으로 성립한다. -- **`state`는 해석하지 않는다**: plugin이 고른 짧은 문자열이며 코어는 뜻을 모른다. 유일한 예외가 - hub 자신이 보내는 `"cancelled"`(`hub_recovery::RECOVERY_CANCELLED`)이고, 클라이언트는 이것을 - "이 pane에 더는 대기 중인 것이 없다"로 읽어 엔트리를 **지운다**. -- **hold가 끝나는 모든 경로가 `cancelled`를 보낸다**: 취소, TTL 만료, relaunch 성공, 명시적 - close. 하나라도 빠지면 클라이언트에 지나간 deadline이 영구히 남는다. -- **취소는 hold를 근거로 판정한다**: `claim_pending`이 비면 아무 일도 하지 않는다(에러가 아니다 — - 클라이언트는 만료보다 한 박자 늦을 수 있다). hold가 있으면 `pane_closed` → `Plugins::forget` → - `retire_slot` 순서다. `forget`이 slot의 토큰으로 예산을 지우므로 `retire_slot`보다 앞이어야 한다. -- **TUI는 행을 추가하지 않는다**: 표시는 (1) pane 탭 라벨의 짧은 마커(`⏳17:45` / `⚠3`, - `ui/terminal_tab/recovery.rs`)와 (2) notice row 마지막 칩(state·deadline·attempt·detail, - `ui/notice.rs`)뿐이다. 전용 행이나 오버레이를 만들지 않은 이유는 "Layout"·"Notice Row"와 - 같다 — 행이 생겼다 사라지면 열려 있는 모든 PTY가 리사이즈된다. 좁은 pane에서는 제목이 - 먼저 잘리고 마커가 남는다(`RECOVERY_TITLE_MAX_CHARS`). -- **취소 키는 leader 뒤에 있다**: ` c`. bare 키는 pane 안 프로그램의 것이라는 "Keyboard - Routing" 규칙 그대로이며, 대기 중인 것이 있을 때만 힌트에 노출된다. -- **탭이 없는 pane도 가리킬 수 있어야 한다**: 프로세스가 끝나고 slot만 남은 pane은 클라이언트의 - pane 목록에 없다. 그래서 표시·취소 대상은 "focus된 pane의 보고, 없으면 목록에 없는 pane의 - 보고(가장 낮은 id)"로 정의된다(`TerminalState::recovery_focus`, 웹은 - `lib/recovery.ts::orphanRecovery`). 웹에서는 그런 보고가 pane 셀 대신 패널 툴바에 뜬다. -- **deadline은 절대 추측하지 않는다**: `deadline_epoch`가 없으면 시각을 아무것도 그리지 않는다. - 틀린 벽시계 시각은 사실처럼 읽힌다. TUI는 날짜 크레이트 없이 `libc::localtime_r`로 `HH:MM`만 - 만들고(`ui/wall_clock.rs`), unix가 아닌 플랫폼에서는 UTC로 떨어진다. -- **터미널 렌더링과 결합하지 않는다**: 화면 내용이 아니라 pane 메타데이터이므로 emulator/xterm - 경로에 닿지 않는다. TUI는 `TerminalState.recovery` 맵, 웹은 컨트롤 프레임에서 파생된 상태다. - -### 공용 웹 계층 (`src/web/common/`) - -인증·HTTP 프레이밍·SSE·연결 회계는 뷰어가 무엇을 서빙하는지와 무관한 프리미티브라 -`common/`에 분리해 둔다. git 데이터도 터미널도 전혀 모르는 계층이며, 웹 표면이 하나 -더 생기더라도 공유는 정확히 여기까지다. - -- **인증 (`common/auth.rs`)**: 비밀번호를 Argon2로 검증한다(code-server와 동일 방식). 평문 `password`는 시작 시 메모리에서 해시하고, `hashed_password`(PHC)가 있으면 그쪽이 우선한다. 로그인은 rate-limit(2/분 + 14/시간)되고 성공 시 httpOnly 세션 쿠키를 발급한다. **쿠키 이름은 서버가 정한다** — 같은 호스트의 다른 서버가 여기서 발급한 세션으로 인증되면 안 되므로, 이름을 이 계층에 두지 않는다. 기본 바인딩은 loopback이며 **TLS는 없다** — 원격은 SSH 터널/리버스 프록시로 감싼다. 서버 활성 시 비밀번호가 없으면 랜덤 생성해 config에 기록하고(주석 보존) 시작 시 1회 출력한다. -- **스트리밍 응답 (`common/sse.rs`)**: `http::response`는 항상 `Content-Length`와 `Connection: close`를 실으므로, 소켓을 열어 둔 채 이벤트를 덧붙일 경로가 없다. `SseStream`은 자기 헤드를 직접 쓰고 그 시점부터 연결을 소유한다. 매 쓰기마다 flush하며(버퍼에 남은 이벤트는 전달된 이벤트가 아니다), 쓰기 실패를 그대로 전파한다 — 닫힌 탭은 다음 쓰기가 실패할 때만 알 수 있다. event 이름에 개행이 있으면 거부한다(SSE 필드 위조 가능). data는 개행마다 `data:` 라인으로 쪼개므로 별도 방어가 필요 없다. 유일한 소비자는 뷰어의 `GET /api/events`다. -- **연결 회계 (`common/conn.rs`)**: 연결마다 스레드가 하나씩 붙으므로 상한이 없으면 포트에 닿을 수 있는 누구나 프로세스를 고갈시킬 수 있다. 상한 초과분은 accept 루프에서 소켓을 닫는다(거기서 503을 쓰면 멈춘 클라이언트 하나가 뒤의 모든 연결을 막는다). 슬롯은 `ConnectionSlot`의 `Drop`으로 반납돼 장수하는 WS handler와 조기 에러 반환 양쪽에서 새지 않는다. - -### Web Viewer (`src/web/viewer/`, `viewer-ui/`) - -뷰어는 TUI와 **같은 데이터 계층을 읽어 DOM으로 렌더하는 두 번째 프론트엔드**다. `App`/`ui`/`input`을 전혀 참조하지 않으며, 그래서 TUI 없이도(`nightcrow serve`) 동작한다. TUI와 별도 포트·별도 쿠키·별도 비밀번호를 쓴다. - -`viewer-ui/src`는 화면 조립과 재사용 단위를 분리한다. `pages/`는 화면 조립, -`components/`는 재사용 UI(terminal/content/feedback 하위 도메인 포함), `hooks/`는 -UI·터미널·저장소 상태, `lib/`는 API 이외의 순수 도메인/레이아웃 유틸리티, -`styles/`는 전역 스타일을 담당한다. `pages/App.tsx`는 조립만 하고 상태 배선은 -도메인 훅이 쥔다 — 서로만 주고받는 ref들을 App에 늘어놓으면 그 handshake가 -조립 코드에 섞여 하나를 빠뜨렸을 때 원인이 보이지 않는다(`useViewerPrefs`는 -로컬 설정과 폴링 채택을 막는 write 카운터, `useProjectTabs`는 저장소 폴링과 -순서 변경이 공유하는 in-flight·drag·pending ref). `public/`의 SVG는 번들이 참조하는 정적 자산이라 -소스와 분리해 유지한다. `api/`는 서버 wire -계약과 HTTP 클라이언트를 별도로 유지한다. - -- **요청 처리 순서가 설계다** (`viewer/server.rs`): ① Host → ② Origin → ③ 정적 번들(인증 불필요) → ④ 인증 → ⑤ 저장소 조회 → ⑥ 경로 검증. Host 검사가 Origin보다 앞이자 별개인 이유: `origin_allowed`는 Origin과 Host가 *일치한다*는 것만 증명하는데, DNS rebinding 공격자는 둘 다 통제하므로 그 조건을 자명하게 만족시킨다. loopback 바인딩일 때 non-loopback Host를 거부해야 rebinding으로 얻는 same-origin 발판이 막힌다(off-loopback이면 운영자가 네트워크 경로를 책임지므로 적용하지 않는다). 인증을 조회보다 **먼저** 하는 이유는, 그러지 않으면 미인증 클라이언트가 404와 401을 비교해 존재하는 repo id를 열거할 수 있기 때문이다. 정적 번들이 인증 앞에 오는 이유는 그것이 로그인 폼을 그리는 주체이기 때문 — 게이팅하면 로그인할 방법 자체가 사라진다. -- **경로 검증은 `with_repo` 한 곳에서** 한다. 라우트마다 쓰면 빠뜨린다: 실제로 `/api/diff`가 `../../etc/passwd`를 받아들였다. `load_file_diff`는 경로를 파일이 아니라 git pathspec으로 넘겨 검증기에 닿지 않았고, 빈 hunk와 함께 공격자의 경로를 그대로 되돌려줬다. **라우트가 "어떤 로더를 호출하느냐"에 따라 우연히 안전해서는 안 된다.** -- **저장소는 opaque id로만 지정**한다(`catalog.rs`). 클라이언트가 디렉토리를 이름 붙일 수 없으므로 "어느 저장소인가"는 검증할 입력이 아니라 성공하거나 404가 되는 조회다. id는 프로세스 수명 동안 안정적이라, 무관한 탭을 열고 닫아도 다른 id가 재배치되지 않는다. -- **저장소별 런타임**(`runtime.rs`): `SnapshotChannel`은 단일 consumer `mpsc`라 TUI 것을 공유할 수 없어 자기 것을 띄운다. 스냅샷을 wire 페이로드로 한 번만 줄여 팬아웃한다. **팬아웃은 conflate**된다 — 느린 구독자는 최신 상태를 받지, 밀린 과거를 재생하지 않는다(슬롯 1개 + 1-depth 병합 wakeup). 소켓 I/O 중 락을 잡지 않는다. 페이로드가 직전과 동일하면 발행하지 않는다: producer는 변화가 아니라 타이머로 tick하므로, 그러지 않으면 유휴 저장소가 매초 스트리밍하며 seq를 태워 "뭔가 바뀌었나"의 지표로 쓸 수 없게 된다. -- **터미널**(`terminal.rs`)은 **세션의 터미널이고, attach한 TUI가 보는 것과 같은 pane**이다(허브가 PTY를 소유하고 두 전송이 같은 허브에 붙는다 — 위 "세션 공유" 참고). raw PTY 바이트를 그대로 보낸다 — **화면은 서버가 그리지 않는다**(xterm.js가 이미 에뮬레이터다). 허브가 스트림을 파싱하는 것은 딱 한 가지, 다른 방법으로는 알 수 없는 **pane의 모드**를 위해서다(`hub_modes.rs`, 위 "붙는 클라이언트에게는 기록이 아니라 상태를 준다"). 4바이트 LE pane id를 앞에 붙인 **바이너리 프레임** — PTY 읽기는 멀티바이트 시퀀스를 일상적으로 쪼개므로 JSON으로 조기 디코딩하면 브라우저가 재조립하기 전에 깨진다. **출력은 conflate하지 않고 큐잉**한다: 최신 status는 완결된 그림이지만 터미널 바이트는 하나만 빠져도 스트림이 깨지므로, 큐를 넘긴 클라이언트는 조용히 버리지 않고 끊는다. -- **PTY 크기는 확정된 값만 전달한다**(`usePaneSizes.ts`, `ServerMessage::Created`). 리사이즈는 싼 메시지가 아니다 — 자식은 SIGWINCH를 받고 풀스크린 프로그램은 화면을 통째로 다시 그린다. 그래서 두 가지를 막는다. 첫째, **중간값을 보내지 않는다**: 브라우저는 최종 기하에 도달하기까지 여러 중간 상태를 지난다(두 번째 pane이 생기며 그리드가 쪼개짐, 웹폰트 로딩, 브레이크포인트 전환). `fit()`은 즉시 돌리되 — xterm 자기 버퍼만 reflow하고 선을 타지 않으므로 드래그가 매끄럽다 — 서버로 보내는 것만 레이아웃이 멈춘 뒤로 미룬다. 둘째, **`created`가 pane의 현재 크기를 싣는다**: pane의 크기를 아는 것은 그것을 정한 페이지뿐이라, 재접속한 클라이언트는 아무것도 가정하지 못하고 자기 크기를 보내야 했고 그 값이 같아도 자식은 한 번 다시 그렸다. 이제 클라이언트가 그 크기를 채택하므로 같은 레이아웃으로 리로드하면 리사이즈가 0번이다. **그 0번이 화면 복원을 대신 하고 있었다** — 재접속 시 풀스크린 프로그램이 다시 그리는 계기가 바로 그 리사이즈였고, 그것을 없앤 뒤로는 깨진 화면이 남았다. 지금은 화면 복원을 리사이즈의 부수 효과에 기대지 않고 허브가 명시적으로 요청하므로(위 "붙는 클라이언트에게는 기록이 아니라 상태를 준다") 이 최적화는 그대로 유지된다. 셋째, **크기를 모르는 PTY는 만들지 않는다**: 접속하면 서버가 `pending`으로 "사이즈 대기 중인 startup 터미널 N개"를 알리고, 클라이언트가 그 pane들이 차지할 셀을 placeholder로 렌더해 **실제 DOM을 재서** `start`로 답한 뒤에야 PTY가 생긴다(`useStartupSizes`). 그리드 산술이 아니라 버려지는 xterm 하나를 그 셀에 열어 `proposeDimensions()`로 재는데, gap과 셀 헤더를 다시 유도하다 어긋나면 그 오차가 곧 이 핸드셰이크가 없애려던 "잘못된 크기로 태어남"이기 때문이다. **타임아웃은 두지 않는다** — 임의의 시간 상수는 기기마다 다른 브라우저 레이아웃 타이밍을 하나로 못 박는 것이라, 두 가지로 대신했다. 측정 실패의 fallback은 **클라이언트**에 둔다(실패했음을 아는 쪽이 거기다. 빈 `sizes`로 답하면 서버가 기존 기본값으로 연다). 그리고 `started` 플래그를 접속이 아니라 **`start` 도착 시점에 소비**한다 — 그래서 핸드셰이크 도중 끊긴 페이지가 터미널을 데려가지 못하고, 다음 접속자가 제안을 다시 받는다(제안은 미청구 상태인 동안 모든 접속자에게 간다). 둘이 동시에 답하면 CAS로 첫 번째만 이겨 pane은 정확히 한 번 생긴다. -- **터미널 pane 순서는 hub가 authoritative하다**(`terminal.rs::reorder_panes`, `viewer-ui/src/lib/paneOrder.ts`). 클라이언트가 pane 헤더를 드래그하면 원하는 전체 순서를 `reorder`로 보내고, hub는 그것을 살아있는 pane에 맞춰 재조정한 뒤(`canonical_order`: 요청 순서 중 실재하는 id를 먼저, 요청이 빠뜨린 live pane은 현재 순서로 뒤에, 모르는 id·중복은 버림) canonical 순서를 `reordered`로 **전 클라이언트에 broadcast**한다. 클라이언트는 낙관적으로 미리 바꾸지 않고 이 echo를 받아 반영해(`reconcileOrder`, create/close와 같은 패턴) 여러 기기가 한 순서로 수렴한다. **순서는 hub의 pane Vec에 살아서** 재접속 replay(`connect`가 그 순서대로 `Created`를 재생)와 다른 기기가 자동으로 따라온다 — 디스크에는 쓰지 않는다. 서버 재시작은 pane 자체를 파기하고 빈 패널로 복귀하므로 영속화할 상태가 없다. DnD는 HTML5 drag가 아니라 pointer 이벤트라(sidebar divider와 같은 선택) 폰 터치도 마우스와 동일하게 동작한다. 재정렬은 pane id·scrollback·PTY를 건드리지 않고 그리드 배치만 바꾸므로 터미널이 끊기지 않는다. -- **프로젝트 탭 순서도 서버가 authoritative하다**(`catalog.rs::reorder`, `POST /api/repos/order`, `viewer-ui/src/pages/App.tsx`). 헤더 탭을 pointer로 드래그하면(pane 헤더·sidebar divider와 같은 선택이라 폰 터치도 동일) 원하는 id 순서를 보내고, 서버가 그것을 live repo에 맞춰 canonical화한 뒤(pane의 `canonical_order`와 동형 — 재사용한 `reconcileOrder`/`reorderByDrop`을 pane number·repo string 양쪽에 쓰도록 제네릭화) 갱신된 목록을 돌려준다. **pane과 다른 점은 전송 채널이다**: repo 목록에는 전용 WebSocket이 없고 `/api/repos` 폴링뿐이라, broadcast 대신 REST로 순서를 갱신하고 다음 폴링이 그것을 받는다. **순서가 `rebuild`를 견디게** `Catalog`에 명시적 `order` overlay를 두어, `union_paths`가 base+added 자연 순서를 그 위에 정렬한다(순서에 없는 새 repo는 끝에). 폴링이 드래그 직후의 옛 순서를 늦게 들고 와 스냅백하는 것은 세 겹으로 막는다: accent·sidebar 폭과 같은 write-generation 가드(`repoOrderWrites`), 드래그 중 차단(`repoDraggingRef`), 그리고 **reorder POST가 in-flight/큐에 있는 동안 폴링이 순서를 채택하지 않는** pending 가드다(마지막 것이 "카운터는 올랐지만 POST 커밋 전 서버를 읽은 폴링이 generation은 일치하는" 창을 닫는다). 가드가 걸린 폴링은 서버 순서를 버리되 membership(다른 기기의 open/close)은 `reconcileOrder`로 받아들인다. **reorder POST는 클라이언트에서 직렬화**한다(한 번에 하나, 큐에는 최신 순서만) — 두 POST가 별도 커넥션이라 서버 처리 순서가 보장되지 않아, 병렬로 쏘면 서버가 옛 요청을 나중에 커밋해 잘못된 순서로 영속할 수 있기 때문이다. **남는 transient 하나**: 커밋 전 서버를 읽었지만 POST가 정착한 뒤 도착하는 폴링은 여전히 한 번 스냅백할 수 있다 — accent·sidebar 폭이 받아들이는 것과 같은 자기교정(다음 폴링) transient라 서버 revision을 도입하지 않는다(그 둘과 일관된 단순 poll 동기화를 유지). **영속은 open/close와 같은 경계**를 따른다: headless `serve`(`persist=true`)면 `catalog.paths()`가 `workspace.json`의 탭 순서로 저장돼 재시작·다른 기기에 유지되고, TUI 동반 실행에서는 세션 한정이다(그 파일의 주인이 TUI라서). 저장 시 `persist_workspace`는 `ws.active`를 인덱스가 아니라 **이전 활성 path 기준으로 재매핑**한다 — 순서가 바뀌면 같은 인덱스가 다른 repo를 가리키므로, 다음 TUI 실행이 엉뚱한 탭을 활성으로 열지 않게 한다. **한 가지 한계**: `serve`에 `--repo`를 명시하면 그 인자가 시작 순서를 지배해(`main.rs`: "explicit --repo comes first and wins") 그 path들의 저장된 재정렬은 재시작 때 덮인다 — 인자 없는 `serve`(workspace만으로 뜨는 일반적 경우)에서는 저장 순서가 그대로 복원된다. 이는 뷰어 기능이 아니라 기존 startup 우선순위 결정이라 그대로 둔다. 또한 `catalog.reorder`(mutation 락으로 원자적) 자체와 이어지는 `persist_workspace`(파일 IO)는 한 트랜잭션이 아니라, **두 기기가 밀리초 안에 동시에 재정렬하면** 파일이 마지막 라이브 순서보다 한 박자 뒤처질 수 있다(라이브 catalog는 항상 정확, 다음 재정렬이 교정). prefs(accent·폭)의 fire-and-forget 영속 경합과 같은 클래스라, 파일 IO를 catalog 락 안으로 끌어들이는 대신 같은 단순 모델을 유지한다. -- **자원 상한**(`limits.rs`)은 전부 `truncated`로 보고된다. 잘린 목록이 전체인 척하지 않는다. -- **wire 계약은 fixture로 고정한다**(`dto.rs::wire_fixture` → `viewer-ui/api.fixture.json` → `api.contract.test.ts`). Rust DTO와 TS interface가 같은 프로토콜을 손으로 두 번 적고 있어, 한쪽만 고치면 화면이 조용히 빈 값으로 렌더된다. `PROTOCOL_VERSION`은 **의도적인** 호환성 단절을 알릴 뿐 실수를 잡지 못한다. 그래서 서버가 모든 페이로드의 예시를 하나씩 만들어 fixture에 굽고(`UPDATE_API_FIXTURE=1 cargo test the_wire_fixture`), 커밋한 뒤, TS 테스트가 그 JSON을 각 interface에 **대입**한다 — 검사는 `expect`가 아니라 타입 주석이 하고, `npm run build`의 `tsc -b`에서 실패한다. Rust 쪽 변경은 fixture diff로, TS 쪽 미반영은 컴파일 실패로 드러나는 **쌍**이 핵심이다. optional 필드는 있는 경우와 없는 경우를 모두 fixture에 넣어 `skip_serializing_if`가 멈춘 것도 보이게 한다. **필드 추가는 TS 쪽에서 잡히지 않는다**(interface가 언급하지 않는 속성은 대입을 막지 않는다) — 그건 Rust fixture assertion이 잡고, 그게 사람을 `api.ts`로 보낸다. codegen(`ts-rs` 등)을 쓰지 않은 이유는 의존성과 빌드 단계가 늘어나는 데 비해 이 규모에서 얻는 게 fixture 한 장과 같기 때문이다. -- **commit log는 anchor에 고정해 페이지로 받는다**(`/api/log`, `diff.rs::load_commit_log_from`). 클라이언트가 목록 끝에 다다르면 다음 페이지를 요청한다(`IntersectionObserver` 센티넬 — TUI가 커서가 tail에 가까워지면 prefetch하는 것의 웹 대응물). 페이지 크기는 `MAX_LOG_PAGE = 100`으로 TUI의 `commit_log_page_size` 기본값과 맞췄다. **`skip`만으로 페이지를 나누지 않는 이유**: skip은 한 walk 안의 offset이라, 페이지 사이에 커밋이 생기면 이후 offset이 전부 밀려 중복·누락이 생긴다 — 바로 아래 터미널 패널에서 커밋하는 것이 이 뷰어의 일상이다. 그래서 첫 응답이 walk 시작 커밋을 `head`로 실어 보내고, 이후 요청은 `from=`로 그 지점에 고정한다(`revwalk.push(oid)`). `from`이 잘못된 oid면 HEAD로 조용히 넘어가지 않고 400이다 — 클라이언트가 돌려받은 값으로 페이지를 이어가므로, 다른 질문에 답하면 목록이 어긋난다. **"더 있는가"는 한 페이지보다 1개 더 요청해 판정한다**: 정확히 한 페이지를 가져와 같은 수로 capping하면 `truncated`가 참이 될 수 없어, 이전 구현은 히스토리 길이와 무관하게 항상 `false`를 보고했다. **`skip`에는 상한을 두지 않는다.** skip은 revwalk의 `Iterator::skip`이라 한 요청의 순회량은 `skip + page`와 히스토리 길이 중 **작은 쪽**으로 이미 제한된다 — 터무니없는 값을 보내도 저장소를 한 번 걷는 비용이 천장이다. 그 이상을 상한으로 막는 것은 이 서버에서 의미가 없다: 여기까지 온 클라이언트는 **이미 인증을 통과해 대화형 셸을 받은 상태**라(`/ws/term`), 그가 서버에 시킬 수 있는 일 중 revwalk 한 번은 가장 가벼운 축이다. 인증이 신뢰 경계이고, 그 뒤에서 자원 사용을 다투는 것은 방어가 아니라 불편일 뿐이다. 반면 상한은 실질적 손해를 만든다 — 클라이언트에게 "더 있다"고 알린 페이지를 영영 못 주는 상태가 생긴다. **알려진 대가**: 페이지 i는 앞의 `i × MAX_LOG_PAGE`개를 다시 건너뛰므로 끝까지 훑는 총비용이 히스토리 길이에 제곱으로 는다. anchor별 서버측 스냅샷을 캐시하면 없앨 수 있지만, 요청마다 상태가 없다는 이 서버의 성질(TTL·메모리·축출)을 포기해야 한다. 스크롤로 도달하는 깊이에서 페이지당 비용이 밀리초 단위라 그 교환은 하지 않았다. **커서(마지막 커밋 oid에서 다시 walk) 방식은 채택하지 않았다**: 병합 히스토리에서 특정 커밋부터 walk하면 그 커밋의 *조상만* 나오므로, HEAD 기준 날짜순 walk에 끼어 있던 병렬 브랜치의 커밋이 영구히 누락된다. anchor+skip은 같은 walk의 offset이라 그 문제가 없다. **자동 페이징은 렌더된 행 수에 반응한다**(`visibleCommits.length`): `IntersectionObserver`는 intersection *변화*만 보고하는데 페이지가 붙어도 센티넬이 제자리에 남을 수 있어 매 페이지마다 재관찰해야 한다. **필터가 걸린 동안에는 페이징을 멈춘다**: log 필터는 *로드된 것*을 좁히는 것이지 서버 검색이 아니므로, 매치를 찾아 히스토리 전체를 페이지 단위로 걸어 들어가면 안 된다. "보이는 행 수" 기준만으로는 부족하다 — 페이지마다 매치가 하나라도 있으면 계속 재무장되어 결국 전체를 훑는다. 센티넬 자리에는 "로드된 N개를 필터 중, 더 보려면 필터를 지우라"는 행을 그린다. 그러지 않으면 필터된 목록의 끝과 히스토리의 끝이 구분되지 않는다. **페이지 실패는 `logDone`이 아니라 `logStalled`다**: 둘을 합치면 일시적 오류가 히스토리의 끝으로 보고되고, footer 에러는 다음 폴링에 지워져 목록이 짧아진 흔적조차 남지 않는다. 실패 시 센티넬 대신 retry 행을 그린다(요청 폭주도 함께 막힌다). **로그는 탭 진입 시점의 스냅샷이다** — TUI와 달리 HEAD 변경을 감지해 자동 갱신하지 않으며, 탭을 떠나면 페이지가 버려지고 다시 들어올 때 새로 받는다. anchor 고정이 이 성질과 맞물려, 표시 중인 목록과 이어받는 페이지가 같은 히스토리를 가리킨다. -- **`GET /api/repos`는 부트스트랩이다**(`dto.rs::ViewerBootstrapDto`). 저장소 목록에 `hot` 설정·`accent`·`now_ms`가 차례로 얹히면서, 이 응답은 실질적으로 "클라이언트가 렌더를 시작하기 전에 서버와 맞춰야 하는 것 전부"가 됐다. 서버 전역 값에 각각 엔드포인트를 주지 않는 이유는 **클라이언트가 이미 3초마다 이걸 폴링하기 때문**이다 — 새 필드는 감시할 대상을 늘리지 않고 한 폴링 안에 모든 기기로 퍼진다. 반대로 `/api/status`에 얹지 않는 이유는 그쪽이 바이트 동일성으로 dedup되는 hot 스트림이라 설정이 낄 자리가 아니기 때문이다. 경로는 `/api/repos`로 두는데 `POST`(열기)·`DELETE`(닫기)가 같은 자원을 쓰기 때문이고, 페이로드의 실제 역할은 타입 이름에 적는다. 필드는 Rust `ViewerBootstrapDto`와 TS `ViewerBootstrap` 양쪽에 있어야 하며, 이름·타입이 어긋나면 아래 계약 테스트가 잡는다(추가만 한 경우는 잡히지 않는다 — 같은 항목의 한계 참조). -- **프론트엔드**(`viewer-ui/`): React 19 + TypeScript 7 + Vite 8 + Tailwind v4 + `@xterm/xterm` 6. shadcn/ui는 쓰지 않는다 — 기본 톤이 TUI 밀도와 맞지 않아 덮어쓸 것이 더 많았다. `dist/`를 커밋해 `cargo install`에 Node를 요구하지 않는다(build.rs에서 npm을 부르면 Node 없는 설치가 전부 깨진다). CI가 재빌드해 커밋된 번들과 다르면 실패시킨다. -- **사이드바 목록은 잘라내지 않고 가로로 스크롤한다**(`viewer-ui/src/pages/App.tsx`). status/log/tree 목록은 TUI가 `ui/mod.rs`의 `char_offset`으로 긴 경로와 커밋 summary를 좌우로 미는 것과 같은 접근을 취한다. `truncate`를 쓰지 않는 이유는 두 행을 구분하는 것이 대개 경로의 **꼬리**이기 때문이다 — `src/web/viewer/server.rs`와 `terminal.rs`는 말줄임이 지우는 바로 그 부분에서만 갈린다. 단 TUI와 한 가지가 다르다: TUI는 status 코드나 commit short_id 같은 접두 컬럼을 고정한 채 가변 텍스트만 미는 반면, 뷰어는 **행 전체가 함께 스크롤된다**(VS Code 탐색기와 같은 동작). `position: sticky`로 접두를 고정하는 안은 검토 후 기각했다 — sticky 요소가 자기 배경을 들고 hover 상태까지 따라가야 해서, 얻는 것에 비해 행 렌더링이 복잡해진다. - -- **accent는 세션의 것이고, 브라우저는 그것을 칠한다**(`viewer-ui/src/hooks/ui/theme.ts`). 헤더 스와치가 TUI의 ` p`와 같은 순서로 5색을 순환한다. TUI가 ratatui 팔레트 이름 색을 쓰는 것과 달리 브라우저에는 대응물이 없어 hex를 고정하는데, 눈대중이 아니라 기존 amber `#d9a441`(OKLCH L=0.751 C=0.130 h=79.8)의 **명도·채도를 유지한 채 hue만 돌려** 파생시킨다 — 그래야 어느 프리셋을 골라도 ink 스케일 위에서 가독성이 같다. 적용은 root의 `--color-accent` 오버라이드 하나로 끝난다(Tailwind가 accent 유틸리티를 전부 `var(--color-accent)`로 컴파일한다). **저장은 서버(`~/.nightcrow/viewer.json`, `viewer/prefs.rs`), 저장소별이 아니라 세션 전역**이다: 뷰어는 폰·노트북 등 여러 기기에서 열리므로 브라우저마다 색을 다시 고르게 하지 않는다. repo id는 프로세스 수명 동안만 안정적이라 저장소별로 키를 잡으면 재시작마다 설정이 사라진다. 전달은 **클라이언트가 이미 3초마다 도는 `/api/repos` 폴링에 얹는다** — 별도 스트림이나 감시할 엔드포인트가 늘지 않고, 한 기기에서 바꾸면 나머지가 한 폴링 안에 따라온다. 쓰기는 `POST /api/prefs`(cross-site가 트리거할 수 없도록 GET이 아닌 POST, 인증 뒤에 배치). 여기서 유일한 순서 문제는 **클릭 직전에 출발한 폴링 응답이 옛 색을 들고 나중에 도착하는 것**이라, `useViewerPrefs`가 로컬 변경 횟수를 세어 자기보다 오래된 응답의 accent만 버린다(나머지 필드는 그대로 쓴다). localStorage는 이제 **첫 페인트 캐시**로만 남는다: CSP가 인라인 스크립트를 막아(`script-src 'self'`) 번들 실행 전에는 칠할 수 없는데, 거기에 폴링 왕복까지 기다리면 매 로드마다 기본 amber가 번쩍인다. **이 값은 TUI의 것이기도 하다** — 원래는 뷰어 전용이었고 TUI는 저장소별 `accent_idx`를 따로 들고 있었지만, 한 세션을 TUI와 브라우저로 나란히 두면 같은 세션이 두 색으로 보였다. 지금은 attach 클라이언트가 데몬 소켓으로 같은 값을 읽고 쓴다(`web/viewer/session.rs`의 `accent`/`set_accent`, `ServerMessage::Repos`가 실어 나른다). 뷰어의 격리는 그대로다 — 별도 포트·쿠키·비밀번호이고, 누가 물어볼 수 있는지는 여전히 각 전송 계층이 세션에 닿기 전에 정한다. 공유되는 것은 인증이 아니라 세션 상태다. 경계와 뒤집은 이유는 위 "세션 공유" 절에 있다. - -- **마지막으로 보던 프로젝트를 서버가 기억한다**(`prefs.rs::ViewerPrefs::active_repo`, `viewer-ui/src/lib/activeRepo.ts`). 새로고침이나 재접속이 첫 탭이 아니라 떠날 때 보던 프로젝트로 열린다. **저장은 id가 아니라 worktree path다** — repo id는 프로세스 수명 동안만 안정적이라(`catalog.rs`) 재시작 뒤에는 아무것도 가리키지 않거나, 더 나쁘게는 탭 순서가 바뀐 사이 *다른* 프로젝트를 가리킨다. 정작 이 기능이 필요한 순간이 재시작이므로 path가 유일한 안정 키다. 대신 클라이언트는 path를 절대 보지 않는다(카탈로그의 불변식): 서버가 `POST /api/prefs`에서 id→path로 풀어 저장하고, `GET /api/repos`에서 path→id로 되돌려 실어 보낸다. 열려 있지 않은 path는 `null`로 나가고 클라이언트가 첫 탭으로 폴백한다. **목록과 활성 id는 한 스냅샷에서 뽑는다**(`catalog.rs::list_with_active`) — 따로 읽으면 그 사이에 열린 repo 때문에 목록에 없는 id가 실려 나갈 수 있고, 보여줄 수 없는 선택을 받은 클라이언트는 첫 탭으로 폴백한 뒤 그것을 기록해 기억을 영영 덮는다. 살아 있지 않은 id를 보내면 400 — 다른 기기의 close와 경합했다는 뜻이고, 200을 주면서 아무것도 저장하지 않으면 선택이 기억된 것처럼 보이기 때문이다. **채택 규칙은 accent·사이드바 폭과 다르다**: 그 둘은 폴링마다 서버 값을 따라가지만(공유된 "생김새"), 활성 프로젝트는 **우선순위 폴백**이라 이미 살아 있는 프로젝트를 보고 있는 페이지는 그대로 둔다(`resolveActiveRepo`: 현재 선택 → 기억된 것 → 첫 탭). 폰에서 탭을 바꿨다고 노트북이 한 폴링 뒤 읽던 화면에서 끌려 나오면 안 되기 때문이다. 그래서 write-generation 가드도 필요 없다 — 늦게 도착한 폴링이 로컬 선택을 덮을 경로 자체가 없다. **쓰기는 선택이 정해지는 한 곳**(`useRepoPoll`의 effect)에서만 하고, 탭 클릭·picker·탭 닫기·폴백 네 경로가 각자 POST하지 않는다(나중에 추가되는 경로가 잊는 쪽이 된다). 쓰기는 **클라이언트에서 직렬화**한다(한 번에 하나, 큐에는 최신 선택만 — `lib/serialWrite.ts`) — 탭 순서 POST와 같은 이유다. 두 POST가 별도 커넥션이라 서버는 선택 순서가 아니라 도착 순서로 처리하고, 빠르게 두 번 전환하면 **먼저 고른 쪽이 나중에 도착해 남을 수 있다**. accent·사이드바 폭은 이 역전을 감수하지만(다음 폴링이 UI를 서버 값으로 되돌려 최소한 둘이 일치하고, 사용자가 보고 다시 누를 수 있다) 활성 프로젝트는 폴링이 UI를 되돌리지 않으므로 **화면과 서버가 조용히 갈라진 채 다음 로드까지 간다** — 그래서 여기서는 감수하지 않는다. 직렬화의 대가로 **`send`는 반드시 끝나야 한다** — 영원히 매달린 요청 하나가 슬롯을 붙들면 이후 선택이 전부 큐에만 쌓이므로, 이 쓰기에만 `AbortSignal.timeout`을 건다(`fetch`에는 자체 타임아웃이 없다). 이 쓰기는 **조건 없이** 나가서, 첫 로드가 서버에게서 받은 값을 그대로 되돌려 쓰기도 한다. 그 한 번을 아끼려면 "이 페이지가 보낸 값"과 "마지막 폴링이 말한 값"을 대조해야 하는데, 쓰기가 in-flight인 동안 둘이 한 폴링만큼 어긋나므로 그 사이에 일어난 전환이 낡은 값을 읽고 **필요한 쓰기를 건너뛴다**(A→B→A를 3초 안에 하면 서버에 B가 남는다). 로드마다 POST 한 번이 그 상태 대조보다 싸다. 닫힌 탭 때문에 밀려난 폴백도 기록하는데, 그래야 파일에 적힌 프로젝트가 항상 "어떤 클라이언트가 실제로 있던 곳"이 된다. **남는 한계 하나**: 로컬 선택이 아직 없는 새 페이지가, 다른 기기가 방금 고른 값보다 **먼저 만들어졌지만 나중에 도착한** 부트스트랩을 받으면 그 낡은 값을 채택해 되돌려 쓴다(더 새로운 선택을 덮는다). 두 기기가 응답 왕복(로컬이면 ms) 안에 겹쳐 움직여야 성립하고, 덮인 결과도 *열려 있는 두 클라이언트 중 하나가 실제로 보고 있는* 프로젝트다 — 공유된 단일 값에 클라이언트가 둘이면 누군가는 지고, 서버 revision/CAS는 그 tie-break를 "나중에 도착한 쪽"에서 "나중에 고른 쪽"으로 바꿀 뿐 모호함을 없애지 못한다. accent·사이드바 폭이 같은 클래스의 역전을 같은 이유로 감수하는 것과 맞춘다. 클라이언트에서 "서버에서 채택한 값은 되돌려 쓰지 않기"로 좁히는 변형은 실제로 시도했다가 되돌렸다 — 그 상태 추적이 훨씬 흔한 단일 기기 경로에서 쓰기를 통째로 건너뛰게 만들었다(위의 **조건 없이** 참조). **localStorage 캐시는 쓰지 않는다** — accent·폭과 달리 repo id는 프로세스 밖에서 의미가 없고, 어차피 목록이 도착하기 전에는 어떤 탭도 그릴 수 없어 숨길 깜빡임이 없다. TUI의 `workspace.json::active`와도 분리돼 있다(그 파일의 주인은 TUI다). - -- **diff는 unified/split 두 레이아웃을 토글한다**(`viewer-ui/src/lib/diffLayout.ts`). diff pane 헤더의 버튼이 TUI의 `DiffPaneView::{Diff, Split}`(diff pane focus에서 `s` → `diff_load.rs::toggle_diff_split_view`)와 같은 전환을 준다. 페어링은 백엔드를 건드리지 않는다 — JSON `Diff` payload가 이미 라인별 `kind`(`+`/`-`/context)와 하이라이트 span을 담고 있어, `splitHunkRows`가 TUI의 `split_rows`/`flush_split_blocks`(`ui/diff_pane.rs`)를 그대로 포팅해 순서만으로 좌/우 행을 만든다(연속 removed/added를 인덱스별로 짝짓고 짧은 쪽은 blank 셀로 패딩, context는 양쪽 미러링). **저장하지 않는다** — 기본은 unified고, split은 그 diff에 필요할 때 눌러서 보는 것이라 선택이 세션(페이지 로드)을 넘지 않는다. TUI가 `DiffPaneView`에 주는 수명과 같다(`SessionState`에 없어 매 실행 unified로 시작). 되돌아갈 기본값이 뚜렷한 설정이라 accent·사이드바 폭처럼 영속시키지 않는다. **좁은 화면에서는 split을 포기하는 대신 두 면을 상하로 쌓는다**(`DiffView.tsx`의 `SplitHunk`, `flex-col md:flex-row`) — removed 면이 위, added 면이 아래고, 두 면을 가르는 선도 방향을 따라간다(`border-t` → `md:border-l`). 폰에서 열을 나란히 두면 각 열이 코드를 읽을 폭을 못 갖지만, 그렇다고 unified로 접으면 **선호를 켠 채로 토글이 아무 일도 하지 않는** 상태가 되어 화면이 고장난 것처럼 보인다. 상하 스택은 "같은 줄의 before/after를 붙여 본다"는 split의 목적을 폭 없이 유지한다. 그래서 뷰어에는 TUI의 `MIN_SPLIT_WIDTH` 폴백(`diff_viewer.rs`)에 대응하는 폭 문턱이 없고, `layout` 하나가 모든 폭에서 그대로 적용된다(JS 미디어 쿼리 없이 CSS 클래스로만 — `ProjectMenu`·사이드바 접힘과 같은 관례). **행 패딩은 스택에서도 유지한다** — `splitHunkRows`의 blank 셀을 지우면 두 면의 행이 서로 어긋나, 위아래로 떨어져 있어 대응을 눈으로 찾아야 하는 스택에서 오히려 읽기 어려워진다. - -- **줄 번호 gutter는 sticky 칼럼이다**(`viewer-ui/src/components/LineNos.tsx`, `lib/gutter.ts`). TUI가 gutter를 본문과 별개 `Paragraph`로 두는 이유 — 수평 스크롤이 라인을 통째로 밀어 번호가 왼쪽으로 사라진다 — 는 웹에도 그대로 있고, 그 대응물이 `position: sticky; left: 0`이다. 그래서 셀에는 **불투명 배경이 필수**다: kind 틴트(`bg-added/10`)가 반투명이라 베이스가 없으면 밑을 지나가는 코드가 번호 위로 비친다. 셀은 불투명 베이스 위에 행과 같은 틴트를 한 겹 더 얹어, 파낸 홈이 아니라 행의 일부로 읽히게 한다. 폭은 `linenoDigits`가 **diff 전체**의 최대 번호에서 뽑는다(hunk마다 다시 계산하면 hunk 경계를 지날 때 코드의 좌측 경계가 계단진다). 최소 3자리는 TUI의 `MIN_LINENO_DIGITS`와 같은 값이다. 번호는 `select-none`이라 코드를 복사해도 딸려오지 않는다 — `+`/`-` 마커와 같은 처리다. **파일 뷰의 번호는 프로토콜에 없다**: 인덱스가 곧 번호라 서버가 실어 보낼 이유가 없고, `old_lineno`/`new_lineno`는 diff에만 붙는다(`dto/diff.rs`, optional이라 그 줄이 없는 쪽은 필드 자체가 빠진다). hunk 헤더는 TUI와 달리 gutter 자리를 비우지 않고 폭 전체를 쓰는 띠로 남긴다 — 웹에서는 헤더가 배경으로 구분되는 별도 띠라, `@@`를 본문 좌측 경계에 맞출 이유가 사라진다. - -- **사이드바 너비는 divider 드래그로 조절한다**(`viewer-ui/src/hooks/ui/sidebar.ts`). 파일 목록과 diff pane 사이 경계에 얇은 핸들을 두고, 드래그하면 pointer의 사이드바 왼쪽 모서리 기준 거리로 폭을 잡는다(원점은 드래그 시작에 한 번만 재서, 중간 re-layout이 pointer 아래로 원점을 옮기지 못하게 한다). **저장은 accent와 같은 서버 전역**(`~/.nightcrow/viewer.json`, `prefs.rs`)이라 폰·노트북이 같은 split으로 열리고, 첫 페인트 캐시로 localStorage도 함께 쓴다. **저장값은 절대 `[280, 720]px`뿐**(서버가 방어, `adopt`/load도 이 범위로만 clamp)이라, 넓은 화면에서 정한 폭이 좁은 화면에서 읽혀도 잘려 사라지지 않는다. **뷰포트 50% 상한은 표시에만 건다** — grid track이 `min(px, 50vw)`라 창이 좁아지면 폴링이나 드래그를 기다리지 않고 즉시 diff pane이 최소 절반을 지키고, 넓히면 저장값까지 곧바로 회복한다(폰에서 실제로 걸리는 건 이 비율, 큰 모니터에서 720px). 드래그 입력(`resize`)에도 같은 50% 상한을 걸어 divider가 pointer를 놓치지 않게 한다. 드래그 중에는 로컬 상태만 갱신해 pixel마다 요청하지 않고, 놓는 순간(`commit`) 한 번 `POST /api/prefs`로 쓴다 — 단 **가로로 유의미하게(≥`SIDEBAR_DRAG_THRESHOLD_PX`) 움직였을 때만** 커밋한다. 순수 클릭이나 세로 흔들림은 커밋하지 않는데, 표시폭이 `50vw`로 잘린 상태에서 그런 입력이 잘린 값을 절대 저장값에 덮어쓰는 걸 막기 위해서다. 순서 문제는 accent와 같은 방식으로 막는다 — 드래그 직전 출발한 폴링이 옛 폭을 늦게 들고 오면 스냅백하므로 `useViewerPrefs`가 로컬 쓰기 횟수를 세어 자기보다 오래된 응답의 width를 버리고, 드래그가 살아 있는 동안(`draggingRef`)은 어떤 폴링도 채택하지 않는다. **남는 한계도 accent와 동일**하다: 쓰기는 fire-and-forget이라 커밋 직후 POST가 서버에 닿기 전 출발한 폴링 한 번은 옛 폭을 읽어 잠깐 스냅백할 수 있고(다음 폴링이 교정), 빠른 두 드래그의 POST가 역순 도착하면 서버가 옛 값으로 남을 수 있다. 이 전이는 스스로 수렴하고 여러 기기를 동시에 만지는 단일 사용자의 드문 경우라, accent와 같은 단순한 poll 동기화를 유지하려 write-generation/시퀀싱을 넣지 않는다. **divider 더블클릭은 기본 폭(460)으로 복구**한다 — resize 핸들의 관례다. 복구는 뷰포트 캡이 아니라 절대 기본값을 저장해(좁은 화면에서 눌러도 460), 표시는 CSS `min`이 캡한다. 더블클릭은 네이티브 `dblclick` 대신 pointer 핸들러 안에서 판정하는데, 드래그의 `preventDefault`가 합성 click 이벤트를 삼킬 수 있어서다. primary 버튼·완결된(취소 아닌) 클릭 쌍만 인정한다. divider는 md+ 2컬럼 레이아웃에서만 뜬다(그 아래는 스택 단일 컬럼), pane maximize 시엔 숨는다. - -- **패널 최대화는 프로젝트별로 저장한다**(`prefs/maximized.rs`). 브라우저의 ⤢ 버튼은 순수 React 상태여서 새로고침이면 사라졌다. "이 프로젝트의 화면을 어떻게 배치했나"는 **view state**이고, TUI는 그것을 세션 파일에 프로젝트별로(`terminal_fullscreen`·`diff_fullscreen`·`list_fullscreen`) 이미 오래 들고 있었다. 이것이 브라우저 쪽 절반이다. - - **TUI의 파일에 쓰지 않고 공유하지도 않는다.** `workspace.json`은 TUI가 붙어 있는 동안 TUI 소유이고(`ViewerState::persist`), 40행 터미널의 최대화와 1400px 창의 최대화는 애초에 같은 답이 아니다 — `upper_pct`를 `layout.upper_pct`와 나누어 둔 근거 그대로다. **키는 절대 경로**로, `active_repo`와 같은 이유다: repo id는 프로세스 수명만큼만 살아서 디스크에 적으면 재시작 후 아무것도 못 가리키는데, 바로 그 재시작을 넘기려고 있는 값이다. 서버가 응답마다 id로 번역하고 클라이언트는 경로를 모른다. 상한은 TUI와 같은 50개 — 없으면 한 번이라도 연 저장소마다 한 줄씩 쌓인다. - - **"아무것도 최대화 안 됨"은 항목의 부재로 표현한다.** 그게 압도적으로 흔한 상태라, 저장하면 스쳐 지나간 프로젝트마다 "none" 한 줄이 남는다. 클라이언트 쪽 상태는 다른 서버 소유 preference와 같은 자리(`useViewerPrefs`)에 두는데, 현재 프로젝트를 만들어 내는 `useProjectTabs`보다 **위에서** 소유해야 하기 때문이다 — 훅은 맵 전체를 들고, 화면의 프로젝트에 묶는 것은 호출부의 몫이다. poll이 방금 누른 것을 되돌리지 않도록 write counter도 나머지 셋과 같은 방식으로 붙는다. 다만 **localStorage 첫 페인트 캐시는 두지 않는다**: 나머지는 키가 없지만 이것은 repo id가 키이고, id는 프로세스 수명만큼만 사니 캐시된 맵은 재시작 후 엉뚱한 프로젝트를 가리킨다. - -- **터미널 패널 높이도 divider 드래그로 조절한다**(`viewer-ui/src/lib/upperPct.ts`, `hooks/ui/upperPct.ts`, `components/terminal/PanelDivider.tsx`). diff 패널과 터미널 패널이 공유하는 경계에 핸들을 두고, 드래그하면 diff 패널이 차지할 **퍼센트**를 잡는다. **재야 하는 구간이 두 grid track에 걸쳐 있고 그 구간에 해당하는 element가 없어서**, 위쪽 끝은 `
`에서, 아래쪽 끝은 터미널 `
`에서 각각 잰다 — 사이드바가 한쪽 모서리만 재는 것과 다른 지점이고, 둘 다 드래그 시작에 한 번만 재는 이유는 같다(드래그가 두 element를 모두 움직이므로 매 프레임 재면 측정 기준이 따라 움직인다). 제스처 자체(threshold, 드래그 중에는 로컬만 갱신하고 놓을 때 한 번 커밋, 더블클릭 복구, once-only 종료)는 사이드바와 **같은 `useDividerDrag`**를 쓰고 축과 측정만 다르다. **저장은 `~/.nightcrow/viewer.json`의 `upper_pct`**(`[20, 85]`로 write와 load 양쪽에서 clamp, 기본 55)이고 첫 페인트 캐시로 localStorage도 함께 쓴다. 절대 px이던 사이드바 폭과 달리 **뷰포트 상한이 필요 없다** — 퍼센트는 이미 읽는 화면 기준이다. 폴링 스냅백은 accent·폭과 같은 write-generation 가드와 드래그 중 차단으로 막고, 남는 transient(커밋 직후 POST가 서버에 닿기 전 출발한 폴링 한 번)도 그 둘과 똑같이 받아들인다. - - **사이드바 폭과 달리 TUI와 공유하지 않는다.** TUI에는 대응 값이 있다(`config.layout.upper_pct`, 기본 55 — 뷰어가 하드코딩하고 있던 `11fr/9fr`과 같은 숫자다). 그래도 accent처럼 세션 소유로 올리지 않은 이유가 셋이다. (1) 퍼센트는 40행 터미널과 1400px 창에서 서로 다른 것을 가리키므로 **수렴할 단일 답이 없다** — accent를 클라이언트별에서 세션 소유로 뒤집은 근거("어느 쪽이 이 세션의 색이냐는 물음에 답할 수 있는 값이 아예 없었다")가 여기서는 성립하지 않는다. (2) 이 값이 지배하는 것처럼 보이는 PTY 크기는 이미 **한 클라이언트가 정한다**(`web/viewer/size_owner.rs`) — 비소유 클라이언트는 resize를 보내지 않으므로, 비율을 공유하면 관전자의 **패널만 움직이고 그 안의 그리드는 그대로**여서 여백이나 잘림만 늘어난다. (3) "터미널에 화면을 얼마나 줄까"는 보고 있는 화면에 대한 질문이라 위 목록의 **fullscreen과 같은 계열**이다 — 이산 버전(maximize)이 클라이언트별인데 연속 버전을 공유로 두면 어긋난다. 그래서 `config.toml`의 `upper_pct`는 `[theme] name`처럼 "아직 아무도 고르지 않은 세션의 시작값"이 되지 않고 **그 머신 TUI의 레이아웃 설정으로 그대로 남는다.** - - **divider는 앱 grid의 다섯 번째 자식이 될 수 없다.** 최상위 grid는 DOM 자식 순서에 걸린 auto-placement로 매 브레이크포인트에서 보이는 4개를 같은 track에 떨어뜨리므로(아래 "폰에서는 세 영역을 동시에 쌓지 않고 하나만 채운다" 참고), element를 하나 더 넣으면 나머지가 엉뚱한 track으로 밀린다. 그래서 터미널 패널 **안에서** 그 패널이 이미 그리는 `border-t` 위에 absolute로 얹는다. maximize 중과 `md` 미만에서는 렌더하지 않는다 — 그 상태의 track은 리터럴이라 퍼센트가 먹지 않고, 폰에서는 세그먼트 바가 뷰를 하나만 채운다. - -- **마크다운은 렌더 뷰로 연다**(`viewer-ui/src/components/content/Markdown.tsx`, `fileView.ts`). tree에서 연 파일 경로가 `.md`/`.markdown`이면 pane 헤더에 rendered/raw 토글이 붙고 **파일을 열 때마다 rendered에서 시작한다**(`usePaneOpeners.ts`의 `openFile`이 리셋, diff 레이아웃과 달리 저장하지 않음). raw는 "이 파일 원문이 뭐지"를 확인하는 일회성 동작이라, 그 선택이 다음에 여는 파일까지 따라오면 왜 raw로 열렸는지 모른 채 되돌려야 한다. 리셋을 `openFile`에 두는 것은 그 경로가 **사용자가 파일을 여는 동작에만** 있기 때문이다(트리 클릭·트리 검색 결과) — 폴링이나 자동 갱신에는 없으므로 읽는 도중에 뷰가 뒤집히지 않는다. 렌더는 `react-markdown`(+`remark-gfm`, `rehype-highlight`)이 AST를 React 엘리먼트로 만들어 수행한다 — `dangerouslySetInnerHTML`가 없어 별도 sanitize 없이 XSS 표면이 없고, 번들 자체 포함이라 `default-src 'self'` CSP와 맞는다. 원문은 새 API 없이 `/api/file`의 하이라이트 span에서 복원한다(span은 색만 담고 문자를 바꾸지 않으므로 `fileViewSource`의 이어붙이기가 줄 내용을 그대로 되살린다). **줄 단위로는 무손실이지만 바이트 단위로는 아니다** — 서버가 `str::lines()`로 쪼개므로 CRLF의 `\r`와 파일 끝 개행이 사라진다. 마크다운에도 HTML 프리뷰에도 보이는 차이는 없다(HTML 파서는 어차피 CRLF를 정규화하고, 끝 개행은 `` 뒤라 렌더에 영향이 없다). 바이트 동일성이 필요해지면 그때 DTO에 줄끝을 실어야 한다. Terminal처럼 lazy-load라 초기 청크에 remark/highlight.js 파이프라인이 들어가지 않는다. 스타일은 `index.css`의 `.nc-markdown` 스코프, 코드 토큰 색은 컴포넌트가 import하는 highlight.js 테마가 준다. **한계**: 문서 내 외부 이미지는 CSP `default-src 'self'`가 막아 로드되지 않는다(깨진 이미지로 표시). **이 한계를 풀지 말 것** — 아래 HTML 프리뷰가 "외부로 아무것도 요청하지 않는다"를 이 CSP에 기대고 있어, `img-src`를 원격으로 여는 순간 저장소의 HTML 한 장이 비콘이 된다. - -- **HTML은 sandbox iframe으로만 연다**(`viewer-ui/src/components/content/Html.tsx`). `.html`/`.htm`도 마크다운과 같은 rendered/raw 토글을 갖는다(플래그를 공유하므로 `previewRendered`라는 이름이다). 다만 **렌더 방식이 다른 이유가 있다**: 마크다운은 AST를 React 엘리먼트로 만들어 원문의 HTML이 애초에 DOM에 닿지 않지만, HTML 파일은 내용 자체가 실행 가능한 문서라 "렌더한다 = 실행한다"이다. 그리고 **이 origin에는 터미널 WebSocket이 붙어 있다** — 여기서 스크립트가 돌면 인증된 세션으로 서버에 셸을 띄울 수 있고, 클론 기능이 있어 "남의 저장소를 열어본다"가 실제 경로다. 그래서 `