From 8878c7ccb6b5252bb06cf1c09d1b619bae8b3c10 Mon Sep 17 00:00:00 2001 From: whackur Date: Sat, 1 Aug 2026 01:46:47 +0900 Subject: [PATCH 1/4] docs(architecture): split the design doc into an index and detail pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The single 1261-line file had grown past what anyone reads end to end, and its Stack table and module tree had drifted from the source: portable-pty was listed at 0.8 (0.9), toml at 0.8 (0.9), WebMirrorConfig no longer exists, and daemon/, backend/hub.rs, git/clone/ and web/viewer/clone_jobs/ were missing entirely. architecture.md keeps its path — AGENTS.md, the self-review skill, and six source comments link to it — and becomes a 236-line index: overview, layout, an accurate module map, the stack, the critical risk, and links into docs/architecture/. The per-area rationale moves into session.md, git-views.md, terminal.md, ui.md, plugin-host.md, and web.md, condensed to the decisions and invariants with the incident retellings cut to a line. --- docs/architecture.md | 1337 ++++-------------------------- docs/architecture/git-views.md | 136 +++ docs/architecture/plugin-host.md | 178 ++++ docs/architecture/session.md | 308 +++++++ docs/architecture/terminal.md | 171 ++++ docs/architecture/ui.md | 185 +++++ docs/architecture/web.md | 442 ++++++++++ 7 files changed, 1576 insertions(+), 1181 deletions(-) create mode 100644 docs/architecture/git-views.md create mode 100644 docs/architecture/plugin-host.md create mode 100644 docs/architecture/session.md create mode 100644 docs/architecture/terminal.md create mode 100644 docs/architecture/ui.md create mode 100644 docs/architecture/web.md 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이 붙어 있다** — 여기서 스크립트가 돌면 인증된 세션으로 서버에 셸을 띄울 수 있고, 클론 기능이 있어 "남의 저장소를 열어본다"가 실제 경로다. 그래서 `