From 4c8359df7c7c835eaea2124c323d323c8504fd39 Mon Sep 17 00:00:00 2001 From: Coden Date: Sun, 14 Jun 2026 10:49:15 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(render):=20--source=20codex=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(lterm-free=20tmux=20statusline=20?= =?UTF-8?q?=EA=B2=BD=EB=A1=9C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit codex CLI는 command-backed statusline을 네이티브 미지원(FR openai/codex#17827). 기존 우회는 lterm 데몬 경유였으나, tmux status-line 등에서 understatus를 직접 호출하는 lterm-free 경로를 위해 `--source codex`를 추가한다. - Source::Codex는 Source::Lterm과 동일한 parse_lterm_input 파서 + codex enrich를 공유한다. enrich 게이팅을 `Lterm | Codex`로 확장(Claude 경로는 여전히 제외). - codex 데이터는 understatus가 ~/.codex 세션을 직접 판독하므로 lterm 데몬 불필요. stdin에 {"agent":"codex","cwd":"..."}만 주면 enrich가 model/ctx%/rate-limit를 채운다. - ctx/chain 게이팅은 Claude 한정 유지(codex는 자연 no-op). - parser/help/에러 메시지 claude|lterm|codex 동기화 + parse 테스트 2개. 검증: fmt/clippy -D warnings 클린, test 343+15=358 그린, --source codex 출력의 ESC 바이트 0(plain-text 계약, tmux/SetUserVar 안전)·후행개행 없음 E2E 확인. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/claude.rs | 2 +- src/codex.rs | 2 +- src/main.rs | 45 +++++++++++++++++++++++++++++++++++---------- 3 files changed, 37 insertions(+), 12 deletions(-) diff --git a/src/claude.rs b/src/claude.rs index 2fcb066..0412b98 100644 --- a/src/claude.rs +++ b/src/claude.rs @@ -303,7 +303,7 @@ pub fn parse_lterm_input(raw: &str) -> ClaudeInput { session_id: session_key, // lterm 세션/페인 표시 라벨(status row에 cwd 앞 표시용). session_label, - // codex enrich는 호출부(main.rs)에서 Source::Lterm 한정으로 별도 수행한다(초기 None). + // codex enrich는 호출부(main.rs)에서 Source::Lterm·Codex 한정으로 별도 수행한다(초기 None). codex: None, } } diff --git a/src/codex.rs b/src/codex.rs index ea34dae..34ea51d 100644 --- a/src/codex.rs +++ b/src/codex.rs @@ -759,7 +759,7 @@ fn is_codex_model(model: &str) -> bool { /// Codex 세션을 판독해 [`ClaudeInput`]을 in-place로 enrich한다(spec §7 게이팅). /// /// # 게이팅(이중 + observability) -/// - 호출부([`crate::main`])에서 **`Source::Lterm`으로 한정**해 호출한다(Claude 경로 오발동 차단). +/// - 호출부([`crate::main`])에서 **`Source::Lterm`·`Source::Codex`로 한정**해 호출한다(Claude 경로 오발동 차단). /// - 추가로 `cfg.codex.enabled` && model이 codex 계열 && `input.cwd=Some` && `codex_home()` 존재. /// /// 단일 해소면 `model_display_name`/`context_used_percentage`/`codex`를 설정한다. 모호/없음/실패 diff --git a/src/main.rs b/src/main.rs index 94ff034..745881f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -85,16 +85,21 @@ fn has_extra_args(args: &[String]) -> bool { args.len() > 2 } -/// 렌더 입력 소스(spec §6.1). `--source `로 선택하며 기본은 claude. +/// 렌더 입력 소스(spec §6.1). `--source `로 선택하며 기본은 claude. /// /// - `Claude`: 기존 동작(Claude Code stdin JSON 파싱 + chain 가능). /// - `Lterm`: lterm 합성 JSON 파싱(git 비활성, chain 기본 off). +/// - `Codex`: `Lterm`과 동일한 합성 JSON 파서를 공유하되, lterm 데몬 없이 +/// tmux status-line 등에서 직접 호출하는 용도다. codex enrich(`~/.codex` 직접 판독)가 +/// `Lterm`과 동일하게 활성화되어 model/ctx%/rate-limit를 채운다(stdin에 `agent`/`cwd`만 주면 됨). #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum Source { /// Claude Code stdin JSON(기본값). Claude, /// lterm 합성 JSON(`--source lterm`). Lterm, + /// lterm-free 직접 호출(`--source codex`). `Lterm`과 파서·enrich를 공유한다. + Codex, } /// 렌더 출력 표면(surface) 형식. `--surface-format `로 선택하며 기본 Oneline. @@ -117,7 +122,7 @@ enum SurfaceFormat { /// render 경로의 파싱된 플래그. struct RenderArgs { - /// `--source `. 미지정 시 [`Source::Claude`]. + /// `--source `. 미지정 시 [`Source::Claude`]. source: Source, /// `--oneline`. true면 chain 미수행 + 후행 개행 없이 1행 출력(spec §6.3). oneline: bool, @@ -154,9 +159,9 @@ fn parse_render_args(args: &[String]) -> Result { while index < args.len() { match args[index].as_str() { "--source" => { - let value = args - .get(index + 1) - .ok_or_else(|| "--source 뒤에 값이 필요합니다(claude|lterm).".to_string())?; + let value = args.get(index + 1).ok_or_else(|| { + "--source 뒤에 값이 필요합니다(claude|lterm|codex).".to_string() + })?; source = parse_source(value)?; index += 2; } @@ -204,8 +209,9 @@ fn parse_source(value: &str) -> Result { match value { "claude" => Ok(Source::Claude), "lterm" => Ok(Source::Lterm), + "codex" => Ok(Source::Codex), other => Err(format!( - "알 수 없는 source '{other}'. 사용 가능: claude|lterm." + "알 수 없는 source '{other}'. 사용 가능: claude|lterm|codex." )), } } @@ -641,7 +647,8 @@ fn run_render_pipeline(source: Source, oneline: bool, surface_format: SurfaceFor // (2) 소스별 세션 정보 파싱(누락/null/깨진 JSON 안전). lterm은 git 비활성. let mut claude_input = match source { Source::Claude => claude::parse_claude_input(&raw_stdin), - Source::Lterm => claude::parse_lterm_input(&raw_stdin), + // lterm·codex는 동일 합성 JSON 파서를 공유한다(codex는 lterm 데몬 없이 tmux 등에서 직접 호출). + Source::Lterm | Source::Codex => claude::parse_lterm_input(&raw_stdin), }; // 세션 캐시 격리 키를 한 곳에서 1회 살균한다(§11.3). session_id 부재/빈 값은 "default"로 폴백. @@ -650,10 +657,11 @@ fn run_render_pipeline(source: Source, oneline: bool, surface_format: SurfaceFor // (5) 설정 로드(부재/깨짐 시 기본값). let cfg = config::load_config(); - // (5') Codex 세션 심층판독 enrich(spec §7). **Source::Lterm 한정**: Claude 경로에서 모델 + // (5') Codex 세션 심층판독 enrich(spec §7). **Source::Lterm·Codex 한정**: Claude 경로에서 모델 // 별칭이 우연히 codex 계열이어도 ~/.codex를 읽지 않도록 여기서 게이팅한다(비트 동일 보존). + // `--source codex`는 lterm 데몬 없이 직접 호출하는 경로로, 같은 파서·enrich를 공유한다. // enrich는 session_id를 바꾸지 않으므로 위 session_key 도출/이후 파이프라인에 영향 없다. - if source == Source::Lterm { + if matches!(source, Source::Lterm | Source::Codex) { codex::maybe_enrich(&mut claude_input, &cfg); } @@ -776,7 +784,7 @@ fn print_help() { \x20 understatus --version 버전 출력\n\ \n\ render 옵션(understatus render 뒤에 사용):\n\ - \x20 --source 입력 소스(claude|lterm). 미지정 시 claude.\n\ + \x20 --source 입력 소스(claude|lterm|codex). 미지정 시 claude.\n\ \x20 --oneline chain 없이 코어 한 줄만 후행 개행 없이 출력(terse, status row용).\n\ \x20 --surface-format 출력 표면(oneline|cmux-status). 미지정 시 oneline.\n\ \x20 (--surface-format은 표면 선택, --oneline은 terse 여부 — 직교)\n\ @@ -861,6 +869,23 @@ mod tests { assert!(!parsed.oneline); } + /// `--source codex` → Codex 소스 + oneline 동시 지정 진입(lterm-free tmux 경로). + #[test] + fn parse_render_args_source_codex() { + let parsed = parse_render_args(&render_argv(&["--source", "codex", "--oneline"])) + .expect("파싱 성공"); + assert_eq!(parsed.source, Source::Codex); + assert!(parsed.oneline); + } + + /// `--source` 마지막 값이 codex여도 last-wins 계약이 유지되어야 한다. + #[test] + fn parse_render_args_duplicate_source_codex_last_wins() { + let parsed = parse_render_args(&render_argv(&["--source", "lterm", "--source", "codex"])) + .expect("파싱 성공"); + assert_eq!(parsed.source, Source::Codex); + } + /// 미지 source 값은 에러(ExitCode::FAILURE로 이어짐). #[test] fn parse_render_args_rejects_unknown_source() { From b95e7d64a0680b63c16a9214b61cfc06246a6f5e Mon Sep 17 00:00:00 2001 From: Coden Date: Sun, 14 Jun 2026 11:08:53 +0900 Subject: [PATCH 2/2] =?UTF-8?q?test(render):=20codex=20enrich=20=EA=B2=8C?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=20=ED=9A=8C=EA=B7=80=20=EC=9E=A0=EA=B8=88=20?= =?UTF-8?q?+=20should=5Fenrich=5Fcodex=20=EC=B6=94=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit quad-review-loop Claude 트랙 MEDIUM 반영. 인라인 matches!(source, Lterm|Codex)를 순수 함수 should_enrich_codex로 추출하고, Claude=no-op·Lterm/Codex=발화 불변식을 단위 테스트(should_enrich_codex_gate)로 고정한다. run_render_pipeline이 stdin/~/.codex I/O를 타 직접 테스트가 어려운 게이트를, 순수 함수 추출로 회귀-0을 코드로 보장한다. quad-review-loop round 1, finding: MEDIUM|testability|src/main.rs|enrich-gate-test-gap Co-Authored-By: Claude Opus 4.8 (1M context) --- src/main.rs | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/src/main.rs b/src/main.rs index 745881f..889f9ae 100644 --- a/src/main.rs +++ b/src/main.rs @@ -216,6 +216,16 @@ fn parse_source(value: &str) -> Result { } } +/// 해당 소스가 Codex 세션 심층판독(`~/.codex` 직접 판독) enrich를 수행해야 하는지 판정한다. +/// +/// `Lterm`·`Codex`만 `true`다(둘 다 `parse_lterm_input` 기반 lterm 합성 JSON 경로). +/// `Claude`는 `false` — Claude payload의 모델 별칭이 우연히 codex 계열이어도 `~/.codex`를 +/// 읽지 않도록 차단해 기존 Claude 경로를 **비트 단위로 보존**한다(회귀 0). 인라인 `matches!`를 +/// 순수 함수로 추출해 게이트 불변식을 단위 테스트로 고정한다(`should_enrich_codex_gate`). +fn should_enrich_codex(source: Source) -> bool { + matches!(source, Source::Lterm | Source::Codex) +} + /// 설치 가능한 테마 기본값(미지정 + 비TTY/`--yes` 폴백). const DEFAULT_THEME: &str = "calm"; /// 설치 가능한 갱신 주기 기본값(초). @@ -661,7 +671,7 @@ fn run_render_pipeline(source: Source, oneline: bool, surface_format: SurfaceFor // 별칭이 우연히 codex 계열이어도 ~/.codex를 읽지 않도록 여기서 게이팅한다(비트 동일 보존). // `--source codex`는 lterm 데몬 없이 직접 호출하는 경로로, 같은 파서·enrich를 공유한다. // enrich는 session_id를 바꾸지 않으므로 위 session_key 도출/이후 파이프라인에 영향 없다. - if matches!(source, Source::Lterm | Source::Codex) { + if should_enrich_codex(source) { codex::maybe_enrich(&mut claude_input, &cfg); } @@ -892,6 +902,21 @@ mod tests { assert!(parse_render_args(&render_argv(&["--source", "bogus"])).is_err()); } + /// codex enrich 게이트 불변식(회귀 0 잠금): Lterm·Codex만 발화하고 Claude는 no-op. + /// + /// `run_render_pipeline`이 stdin/`~/.codex` I/O를 타 직접 테스트가 어려우므로, 게이트를 + /// 순수 함수로 추출해 여기서 잠근다. 향후 게이트 리팩토링이 Claude 경로를 오발동시키면 + /// (예: `_ => true`) 이 테스트가 즉시 실패한다. + #[test] + fn should_enrich_codex_gate() { + assert!(should_enrich_codex(Source::Lterm), "Lterm은 enrich 발화"); + assert!(should_enrich_codex(Source::Codex), "Codex는 enrich 발화"); + assert!( + !should_enrich_codex(Source::Claude), + "Claude는 enrich no-op(회귀 0)" + ); + } + /// `--source` 값 누락은 에러여야 한다. #[test] fn parse_render_args_rejects_missing_source_value() {