feat(kebab-tui): p9-fb-14 color theme module — role-based palette

도그푸딩 item 12 — TUI 가 모든 정보 종류에 같은 회색 / 시안 만 쓰던
\"빈약한 색감\" 해소. inline `Style::default().fg(Color::*)` 호출을
single source `theme` 모듈로 격리 + dark / light 두 팔레트 제공.

## 핵심 변경

- **`kebab-tui::theme::{Theme, Role, Palette}`** 신규 (132 라인). 16
  개 Role enum (BorderActive/BorderInactive/Title/Path/ModeLexical/
  ModeVector/ModeHybrid/Selected/Hint/Heading/Warning/Error/Success/
  CitationMarker/Bullet/Body) 을 dark + light 두 팔레트가 exhaustive
  match 로 매핑. 새 Role 추가 시 두 팔레트 모두 갱신해야 컴파일됨.

- **`Theme::from_name(s)`** — 알 수 없는 값 (e.g. \"solarized\") →
  dark fallback. config typo 가 TUI 를 죽이지 않음 (spec 명시).

- **`App.theme: Theme`** 신규 — `App::new` 가 `config.ui.theme` 에서
  resolve. 모든 pane (library/search/ask/inspect/run/error_popup) 이
  `app.theme.style(Role::X)` 로 style 가져옴.

- **`Config.ui.theme: String`** 신규 — `[ui] theme = \"dark\" | \"light\"`
  (default `\"dark\"`). `#[serde(default)]` 로 기존 config 파일 호환.

- **Pane sweep**: search.rs / ask.rs / library.rs / inspect.rs /
  run.rs / error_popup.rs 의 모든 inline `Style::default().fg(Color::*)`
  / `add_modifier(Modifier::DIM/REVERSED)` 호출 제거. 일부 helper
  (`render_filter_overlay`, `header_kv`, `kv`, `push_section_header`,
  `build_doc_lines`, `build_chunk_lines`, `render_input/answer/bottom/
  status/citations`, `render_error_overlay`) 가 `theme: &Theme` 파라
  미터 추가.

## Out of scope

- `T` 키 runtime toggle — mode machine (p9-fb-12) 미진행이라 NORMAL
  모드 정의 불가, config 만으로 결정. 추후 p9-fb-12 후속에서 추가.
- 사용자 정의 `[theme.custom]` 절 — P+ task.
- truecolor → 256-color fallback — terminal 가정.

## 테스트

- 신규 4 개 (theme.rs):
  - `every_role_resolves_in_dark_and_light` — 16 Role 전부 panic 없이
    Style 반환 (exhaustive match runtime 검증)
  - `from_name_recognizes_dark_light_and_falls_back` — 입력 정규화 +
    fallback 정책
  - `default_palette_is_dark` — 기본값 pin
  - `primary_roles_carry_decoration_in_dark` — Title/Selected/Heading/
    Error/Warning/Success 가 bare default 로 회귀 안 함
- 기존 75 개 TUI 테스트 (14 lib + 18 ask + 12 inspect + 10 library +
  17 search + 4 theme) 모두 통과
- `cargo test --workspace --no-fail-fast -j 1` exit 0
- `cargo clippy -p kebab-tui -p kebab-config --all-targets -- -D warnings`
  clean

## 문서

- README Configuration 절: `[ui]` 섹션 + `theme = \"dark\"|\"light\"`
  안내
- docs/SMOKE.md: config 예시에 `[ui] theme = \"dark\"` 라인 추가
- HANDOFF: 2026-05-03 머지 후 발견 entry
- spec status: planned → in_progress

p9-fb-11 (ask markdown render) 의 `Theme` 의존성 unblock.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-03 03:09:53 +00:00
parent 5b5d35852b
commit afb65702b6
14 changed files with 463 additions and 121 deletions

View File

@@ -49,6 +49,7 @@ P0~P5 직렬. P6~P9 P5 이후 병렬 가능.
- **2026-05-03 P9 도그푸딩 후속 (p9-fb-20)** — `kebab ask` 의 CLI citation block. 답변 출력 후 `근거:` 절 — `[N] <full path>#<fragment> (score=<s>)` 한 줄씩. `--show-citations` (default ON) / `--hide-citations` (pipe 시 답변 본문만) flag. `--json` 모드는 무영향 (citations 가 항상 wire payload 에 포함). spec p9-fb-20 의 \"TUI citation pane + jump\" 부분은 P9-3 의 기존 `render_citations_or_explain` 가 일부 cover — 추가 기능 (turn 별 fold + Enter/o jump + i inspect) 은 후속 task 로 미룸 (사용자 도그푸딩 priority 5위 의 핵심 = full path 가독성 = CLI block 으로 충족). spec: `tasks/p9/p9-fb-20-citation-surface.md`.
- **2026-05-03 P9 도그푸딩 후속 (p9-fb-07)** — Markdown title fallback chain. `kebab-normalize::derive_title(frontmatter_title, &[Block], file_stem)` — 1) frontmatter title → 2) 첫 H1 → 3) 첫 H2 → 4) 첫 paragraph 80 chars → 5) 파일 stem (모든 단계 NFC 정규화, 빈 문자열 절대 반환 안 함, 마지막 sentinel `"untitled"`). `build_canonical_document` 가 lift 후 helper 호출. parser_version 상수 `pulldown-cmark-0.x``md-frontmatter-v2` bump — 기존 doc 은 `doc_id` 가 갱신되므로 다음 ingest 가 자동 재처리 (idempotent upsert, design §9 cascade). spec: `tasks/p9/p9-fb-07-md-title-fallback.md`.
- **2026-05-03 P9 도그푸딩 후속 (p9-fb-09)** — TUI external editor return restore. Search `g` 키 (citation jump) 후 TUI 화면이 깨지는 버그 수정. `kebab-tui::editor::with_external_program(&mut TuiTerminal, Command)` helper 가 suspend (LeaveAlternateScreen + Show cursor + disable_raw_mode) → spawn → restore (enable_raw_mode + EnterAlternateScreen + Hide cursor + `terminal.clear()`) 시퀀스를 RAII guard 로 atomic 하게 묶음. `App.pending_editor: Option<EditorRequest>` + `App.force_redraw: bool` 추가 — 키 핸들러는 EditorRequest enqueue 만, 실제 spawn 은 run loop 가 `TuiTerminal` 핸들 들고 처리. 후속 task (p9-fb-20 의 citation jump 등) 가 같은 helper 위에 build. spec: `tasks/p9/p9-fb-09-tui-editor-restore.md`.
- **2026-05-03 P9 도그푸딩 후속 (p9-fb-14)** — TUI color theme module. `kebab-tui::theme::{Theme, Role, Palette}` 신규 — 16 개 Role (BorderActive/Title/Path/ModeLexical/ModeVector/ModeHybrid/Selected/Hint/Heading/Warning/Error/Success/CitationMarker/Bullet/Body/BorderInactive) 을 dark + light 두 팔레트가 exhaustive match 로 매핑. 모든 Pane (library/search/ask/inspect/run/error_popup) 의 inline `Style::default().fg(Color::*)` 호출이 `theme.style(Role::X)` 로 격리됨. `Config.ui.theme: String` (default `"dark"`) 신규. `App.theme: Theme``App::new` 에서 `Theme::from_name(&config.ui.theme)` 로 build — 알 수 없는 값은 dark fallback (config 가 typo 로 죽지 않음). `T` 키 runtime toggle 은 mode machine (p9-fb-12) 미진행이라 skip — config 만으로 결정. p9-fb-11 (ask markdown render) 의 Theme 의존성 unblock. spec: `tasks/p9/p9-fb-14-tui-color-theme.md`.
## 다음 task 후보

View File

@@ -145,7 +145,7 @@ flowchart TB
## Configuration
- `~/.config/kebab/config.toml``kebab init` 가 XDG 경로에 생성. `[workspace] include`, `[storage]`, `[chunking]`, `[models.embedding]`, `[models.llm]`, `[image.ocr]`, `[image.caption]`, `[search]`, `[rag]`.
- `~/.config/kebab/config.toml``kebab init` 가 XDG 경로에 생성. `[workspace] include`, `[storage]`, `[chunking]`, `[models.embedding]`, `[models.llm]`, `[image.ocr]`, `[image.caption]`, `[search]`, `[rag]`, `[ui]` 절. `[ui] theme = "dark" | "light"` 로 TUI 팔레트 선택 (default `"dark"`, 알 수 없는 값은 dark fallback).
- `--config <path>` flag — 임시 워크스페이스 / 격리 테스트 시 사용. CLI / TUI 모두 honor.
- `KEBAB_*` env — 일부 키 override (`KEBAB_RAG_SCORE_GATE`, `KEBAB_EVAL_GOLDEN`, `KEBAB_COMMIT_HASH` 등).
- XDG layout: `~/.config/kebab/`, `~/.local/share/kebab/`, `~/.cache/kebab/`, `~/.local/state/kebab/`.

View File

@@ -27,6 +27,11 @@ pub struct Config {
/// (they cost a model call per asset).
#[serde(default = "ImageCfg::defaults")]
pub image: ImageCfg,
/// p9-fb-14: TUI palette + role-style mapping. `#[serde(default)]`
/// so configs that predate this section still load (defaults to
/// `dark`).
#[serde(default = "UiCfg::defaults")]
pub ui: UiCfg,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@@ -194,6 +199,27 @@ impl CaptionCfg {
}
}
/// p9-fb-14: TUI-only configuration. Currently a single `theme`
/// selector (`"dark"` / `"light"`); future fields (custom role
/// overrides, mode-machine cursor shapes, …) extend the same
/// section so the CLI doesn't grow a per-feature `[ui.*]` table.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct UiCfg {
/// Palette name. Recognized: `"dark"` (default), `"light"`.
/// Unknown values fall back to `"dark"` at construction time
/// — config never errors on a typo, the TUI just keeps the
/// default theme so the user has a working shell.
pub theme: String,
}
impl UiCfg {
pub fn defaults() -> Self {
Self {
theme: "dark".to_string(),
}
}
}
impl Config {
/// Defaults per design §6.4.
pub fn defaults() -> Self {
@@ -263,6 +289,7 @@ impl Config {
max_context_tokens: 8000,
},
image: ImageCfg::defaults(),
ui: UiCfg::defaults(),
}
}

View File

@@ -240,6 +240,12 @@ pub const TERMINAL_LINE_HOLD_SECS: u64 = 3;
/// add panes by populating their `Option<*State>` slot.
pub struct App {
pub config: Config,
/// p9-fb-14: resolved palette + role-style mapping. Built once
/// in `App::new` from `config.ui.theme` (`"dark"` / `"light"`,
/// fallback dark on unknown). Every pane reads its styles via
/// `app.theme.style(Role::X)` instead of inlining
/// `Style::default().fg(Color::*)`.
pub theme: crate::theme::Theme,
pub focus: Pane,
pub library: LibraryState,
/// Populated by p9-2 (None until that crate links in).
@@ -295,8 +301,10 @@ impl App {
/// run loop calls `library.refresh` on first frame so a slow
/// `kebab-app::list_docs_with_config` does not block startup.
pub fn new(config: Config) -> anyhow::Result<Self> {
let theme = crate::theme::Theme::from_name(&config.ui.theme);
Ok(Self {
config,
theme,
focus: Pane::Library,
library: LibraryState::new(),
search: None,

View File

@@ -17,7 +17,7 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use kebab_core::{RefusalReason, SearchMode};
use ratatui::Frame;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::style::Modifier;
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
use std::sync::mpsc;
@@ -44,12 +44,12 @@ pub fn render_ask(f: &mut Frame, area: Rect, state: &App) {
])
.split(area);
render_input(f, layout[0], s);
render_answer(f, layout[1], s);
render_bottom(f, layout[2], s);
render_input(f, layout[0], s, &state.theme);
render_answer(f, layout[1], s, &state.theme);
render_bottom(f, layout[2], s, &state.theme);
}
fn render_input(f: &mut Frame, area: Rect, s: &AskState) {
fn render_input(f: &mut Frame, area: Rect, s: &AskState, theme: &crate::theme::Theme) {
let mode_badge = if s.explain { " explain" } else { "" };
// Distinguish three async states for the operator:
// - currently streaming (worker still emitting tokens)
@@ -64,10 +64,10 @@ fn render_input(f: &mut Frame, area: Rect, s: &AskState) {
""
};
let line = Line::from(vec![
Span::styled("? ", Style::default().fg(Color::Cyan)),
Span::styled("? ", theme.style(crate::theme::Role::Heading)),
Span::raw(s.input.as_str()),
Span::styled(mode_badge, Style::default().fg(Color::Yellow)),
Span::styled(busy, Style::default().add_modifier(Modifier::DIM)),
Span::styled(mode_badge, theme.style(crate::theme::Role::Warning)),
Span::styled(busy, theme.style(crate::theme::Role::Hint)),
]);
let block = Block::default()
.title("ask (Enter=submit e=explain Ctrl-L=new conversation Esc=back)")
@@ -75,7 +75,7 @@ fn render_input(f: &mut Frame, area: Rect, s: &AskState) {
f.render_widget(Paragraph::new(line).block(block), area);
}
fn render_answer(f: &mut Frame, area: Rect, s: &AskState) {
fn render_answer(f: &mut Frame, area: Rect, s: &AskState, theme: &crate::theme::Theme) {
let title = if s.turns.is_empty() && !s.streaming {
"transcript".to_string()
} else {
@@ -88,14 +88,20 @@ fn render_answer(f: &mut Frame, area: Rect, s: &AskState) {
// Completed turns first (chronological), then the in-flight
// turn (if any) at the bottom. The most-recent completed
// turn's grounded flag (from `last_answer`) styles its A line
// — yellow on refusal so the user keeps the P9-3 visual
// distinction even inside the transcript.
// via the theme's Warning role on refusal so the user keeps
// the P9-3 visual distinction even inside the transcript.
let last_turn_grounded = s.last_answer.as_ref().map(|a| a.grounded);
let last_turn_idx = s.turns.len().saturating_sub(1);
let mut lines: Vec<Line> = Vec::new();
for (idx, turn) in s.turns.iter().enumerate() {
let style_override = if idx == last_turn_idx {
last_turn_grounded.and_then(|g| if g { None } else { Some(Color::Yellow) })
let role_override = if idx == last_turn_idx {
last_turn_grounded.and_then(|g| {
if g {
None
} else {
Some(crate::theme::Role::Warning)
}
})
} else {
None
};
@@ -105,7 +111,8 @@ fn render_answer(f: &mut Frame, area: Rect, s: &AskState) {
&turn.question,
&turn.answer,
false,
style_override,
role_override,
theme,
);
lines.push(Line::raw(""));
}
@@ -115,13 +122,13 @@ fn render_answer(f: &mut Frame, area: Rect, s: &AskState) {
let mut a = s.partial.clone();
a.push('▍');
let idx = s.turns.len();
push_turn_lines(&mut lines, idx, q, &a, true, None);
push_turn_lines(&mut lines, idx, q, &a, true, None, theme);
}
if lines.is_empty() {
let hint = Paragraph::new(Span::styled(
"(type a question and press Enter. follow-ups inherit history. Ctrl-L clears the conversation.)",
Style::default().add_modifier(Modifier::DIM),
theme.style(crate::theme::Role::Hint),
))
.wrap(Wrap { trim: false });
f.render_widget(hint.block(block), area);
@@ -140,35 +147,36 @@ fn push_turn_lines(
question: &str,
answer: &str,
streaming: bool,
answer_color_override: Option<Color>,
answer_role_override: Option<crate::theme::Role>,
theme: &crate::theme::Theme,
) {
let q_label = format!("Q{}", idx + 1);
let a_label = format!("A{}", idx + 1);
out.push(Line::from(vec![
Span::styled(
q_label,
Style::default()
.fg(Color::Cyan)
theme
.style(crate::theme::Role::Heading)
.add_modifier(Modifier::BOLD),
),
Span::raw(": "),
Span::raw(question.to_string()),
]));
// p9-fb-16: refusal turn (caller passed Yellow) keeps the P9-3
// visual distinction inside the transcript. Streaming turn fades
// to dim gray. Default is plain.
let answer_style = if let Some(c) = answer_color_override {
Style::default().fg(c)
// p9-fb-16: refusal turn (caller passed Role::Warning) keeps the
// P9-3 visual distinction inside the transcript. Streaming turn
// fades to dim hint. Default is plain Body.
let answer_style = if let Some(role) = answer_role_override {
theme.style(role)
} else if streaming {
Style::default().fg(Color::Gray)
theme.style(crate::theme::Role::Hint)
} else {
Style::default()
theme.style(crate::theme::Role::Body)
};
out.push(Line::from(vec![
Span::styled(
a_label,
Style::default()
.fg(Color::Green)
theme
.style(crate::theme::Role::Success)
.add_modifier(Modifier::BOLD),
),
Span::raw(": "),
@@ -176,21 +184,21 @@ fn push_turn_lines(
]));
}
fn render_bottom(f: &mut Frame, area: Rect, s: &AskState) {
fn render_bottom(f: &mut Frame, area: Rect, s: &AskState, theme: &crate::theme::Theme) {
let split = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(40), Constraint::Percentage(60)])
.split(area);
render_status(f, split[0], s);
render_citations_or_explain(f, split[1], s);
render_status(f, split[0], s, theme);
render_citations_or_explain(f, split[1], s, theme);
}
fn render_status(f: &mut Frame, area: Rect, s: &AskState) {
fn render_status(f: &mut Frame, area: Rect, s: &AskState, theme: &crate::theme::Theme) {
let block = Block::default().title("status").borders(Borders::ALL);
let lines: Vec<Line> = match &s.last_answer {
None => vec![Line::from(Span::styled(
"(no answer yet)",
Style::default().add_modifier(Modifier::DIM),
theme.style(crate::theme::Role::Hint),
))],
Some(a) => {
let grounded = if a.grounded { "" } else { "" };
@@ -220,17 +228,17 @@ fn render_status(f: &mut Frame, area: Rect, s: &AskState) {
f.render_widget(Paragraph::new(lines).block(block), area);
}
fn render_citations_or_explain(f: &mut Frame, area: Rect, s: &AskState) {
fn render_citations_or_explain(f: &mut Frame, area: Rect, s: &AskState, theme: &crate::theme::Theme) {
let title = if s.explain { "explain (per-claim)" } else { "citations" };
let block = Block::default().title(title).borders(Borders::ALL);
let lines: Vec<Line> = match &s.last_answer {
None => vec![Line::from(Span::styled(
"(submit a question to see citations)",
Style::default().add_modifier(Modifier::DIM),
theme.style(crate::theme::Role::Hint),
))],
Some(a) if a.citations.is_empty() => vec![Line::from(Span::styled(
if a.grounded { "(no citations)" } else { "(가까운 후보 없음)" },
Style::default().add_modifier(Modifier::DIM),
theme.style(crate::theme::Role::Hint),
))],
Some(a) => a
.citations
@@ -240,7 +248,7 @@ fn render_citations_or_explain(f: &mut Frame, area: Rect, s: &AskState) {
Line::from(vec![
Span::styled(
format!("[{marker}] "),
Style::default().fg(Color::Cyan),
theme.style(crate::theme::Role::CitationMarker),
),
Span::raw(c.citation.to_uri()),
])

View File

@@ -4,10 +4,12 @@
use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::style::Modifier;
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap};
use crate::theme::{Role, Theme};
/// Captured snapshot of an `anyhow::Error` for rendering. We do NOT
/// store the `anyhow::Error` itself (it is `!Sync` in pre-1.0.99
/// versions on some toolchains and would force lifetime gymnastics
@@ -38,16 +40,16 @@ impl ErrorOverlay {
/// Render the popup centred in `area`. Caller is responsible for
/// clearing the underlying region (`Clear` widget); we do that here.
pub fn render_error_overlay(f: &mut Frame, area: Rect, overlay: &ErrorOverlay) {
/// `theme` is threaded so the overlay's red borders / dim hint use
/// the same role-style mapping as every other pane (p9-fb-14).
pub fn render_error_overlay(f: &mut Frame, area: Rect, overlay: &ErrorOverlay, theme: &Theme) {
let popup_area = centered_rect(area, 60, 50);
f.render_widget(Clear, popup_area);
let mut lines: Vec<Line> = Vec::with_capacity(overlay.chain.len() + 2);
lines.push(Line::from(Span::styled(
format!("{}: {}", overlay.title, overlay.chain.first().map_or("(unknown)", String::as_str)),
Style::default()
.fg(Color::Red)
.add_modifier(Modifier::BOLD),
theme.style(Role::Error).add_modifier(Modifier::BOLD),
)));
for cause in overlay.chain.iter().skip(1) {
lines.push(Line::from(format!(" caused by: {cause}")));
@@ -55,13 +57,13 @@ pub fn render_error_overlay(f: &mut Frame, area: Rect, overlay: &ErrorOverlay) {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
"press any key to dismiss",
Style::default().add_modifier(Modifier::DIM),
theme.style(Role::Hint),
)));
let block = Block::default()
.title("error")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Red));
.border_style(theme.style(Role::Error));
let para = Paragraph::new(lines).block(block).wrap(Wrap { trim: false });
f.render_widget(para, popup_area);
}

View File

@@ -19,7 +19,7 @@ use crossterm::event::{KeyCode, KeyEvent};
use kebab_core::{Block, CanonicalDocument, Chunk};
use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::style::Modifier;
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block as RBlock, Borders, Paragraph, Wrap};
@@ -48,9 +48,9 @@ pub fn render_inspect(f: &mut Frame, area: Rect, state: &App) {
return;
}
match (&s.target, &s.doc, &s.chunk) {
(Some(InspectTarget::Doc(_)), Some(doc), _) => render_doc(f, area, s, doc),
(Some(InspectTarget::Doc(_)), Some(doc), _) => render_doc(f, area, s, doc, &state.theme),
(Some(InspectTarget::Chunk(_)), _, Some(chunk)) => {
render_chunk(f, area, s, chunk)
render_chunk(f, area, s, chunk, &state.theme)
}
_ => {
let block = RBlock::default()
@@ -59,7 +59,7 @@ pub fn render_inspect(f: &mut Frame, area: Rect, state: &App) {
let hint = Paragraph::new(Span::styled(
"(no target — return to Library and press Enter on a doc, \
or to Search and press `i` on a hit)",
Style::default().add_modifier(Modifier::DIM),
state.theme.style(crate::theme::Role::Hint),
))
.wrap(Wrap { trim: false });
f.render_widget(hint.block(block), area);
@@ -67,8 +67,8 @@ pub fn render_inspect(f: &mut Frame, area: Rect, state: &App) {
}
}
fn render_doc(f: &mut Frame, area: Rect, s: &InspectState, doc: &CanonicalDocument) {
let lines = build_doc_lines(s, doc);
fn render_doc(f: &mut Frame, area: Rect, s: &InspectState, doc: &CanonicalDocument, theme: &crate::theme::Theme) {
let lines = build_doc_lines(s, doc, theme);
let block = RBlock::default()
.title(format!(
"Inspect Doc — {}",
@@ -81,8 +81,8 @@ fn render_doc(f: &mut Frame, area: Rect, s: &InspectState, doc: &CanonicalDocume
f.render_widget(para.block(block), area);
}
fn render_chunk(f: &mut Frame, area: Rect, s: &InspectState, chunk: &Chunk) {
let lines = build_chunk_lines(s, chunk);
fn render_chunk(f: &mut Frame, area: Rect, s: &InspectState, chunk: &Chunk, theme: &crate::theme::Theme) {
let lines = build_chunk_lines(s, chunk, theme);
let block = RBlock::default()
.title(format!(
"Inspect Chunk — {}",
@@ -100,31 +100,34 @@ fn render_chunk(f: &mut Frame, area: Rect, s: &InspectState, chunk: &Chunk) {
pub(crate) fn build_doc_lines<'a>(
s: &InspectState,
doc: &'a CanonicalDocument,
theme: &crate::theme::Theme,
) -> Vec<Line<'a>> {
let mut lines: Vec<Line> = Vec::new();
// Header
lines.push(header_kv("title", &doc.title));
lines.push(header_kv("doc_path", &doc.workspace_path.0));
lines.push(header_kv("doc_id", &doc.doc_id.0));
lines.push(header_kv("lang", &doc.lang.0));
lines.push(header_kv("title", &doc.title, theme));
lines.push(header_kv("doc_path", &doc.workspace_path.0, theme));
lines.push(header_kv("doc_id", &doc.doc_id.0, theme));
lines.push(header_kv("lang", &doc.lang.0, theme));
lines.push(header_kv(
"source_type",
&format!("{:?}", doc.metadata.source_type).to_lowercase(),
theme,
));
lines.push(header_kv(
"trust_level",
&format!("{:?}", doc.metadata.trust_level).to_lowercase(),
theme,
));
lines.push(header_kv("parser_version", &doc.parser_version.0));
lines.push(header_kv("parser_version", &doc.parser_version.0, theme));
lines.push(blank());
// metadata
push_section_header(&mut lines, SECTION_METADATA, s);
push_section_header(&mut lines, SECTION_METADATA, s, theme);
if !s.collapsed.contains(SECTION_METADATA) {
lines.push(kv("aliases", &format!("{:?}", doc.metadata.aliases)));
lines.push(kv("tags", &format!("{:?}", doc.metadata.tags)));
lines.push(kv("created_at", &fmt_dt(&doc.metadata.created_at)));
lines.push(kv("updated_at", &fmt_dt(&doc.metadata.updated_at)));
lines.push(kv("aliases", &format!("{:?}", doc.metadata.aliases), theme));
lines.push(kv("tags", &format!("{:?}", doc.metadata.tags), theme));
lines.push(kv("created_at", &fmt_dt(&doc.metadata.created_at), theme));
lines.push(kv("updated_at", &fmt_dt(&doc.metadata.updated_at), theme));
// user metadata pretty-printed JSON
if let Ok(pretty) =
serde_json::to_string_pretty(&serde_json::Value::Object(
@@ -139,12 +142,12 @@ pub(crate) fn build_doc_lines<'a>(
}
// provenance
push_section_header(&mut lines, SECTION_PROVENANCE, s);
push_section_header(&mut lines, SECTION_PROVENANCE, s, theme);
if !s.collapsed.contains(SECTION_PROVENANCE) {
if doc.provenance.events.is_empty() {
lines.push(Line::from(Span::styled(
" (no events)",
Style::default().add_modifier(Modifier::DIM),
theme.style(crate::theme::Role::Hint),
)));
} else {
for ev in &doc.provenance.events {
@@ -171,6 +174,7 @@ pub(crate) fn build_doc_lines<'a>(
SECTION_BLOCKS,
s,
Some(doc.blocks.len()),
theme,
);
if !s.collapsed.contains(SECTION_BLOCKS) {
let preview_n = 16.min(doc.blocks.len());
@@ -183,7 +187,7 @@ pub(crate) fn build_doc_lines<'a>(
if doc.blocks.len() > preview_n {
lines.push(Line::from(Span::styled(
format!(" … +{} more", doc.blocks.len() - preview_n),
Style::default().add_modifier(Modifier::DIM),
theme.style(crate::theme::Role::Hint),
)));
}
}
@@ -193,11 +197,12 @@ pub(crate) fn build_doc_lines<'a>(
pub(crate) fn build_chunk_lines<'a>(
s: &InspectState,
chunk: &'a Chunk,
theme: &crate::theme::Theme,
) -> Vec<Line<'a>> {
let mut lines: Vec<Line> = Vec::new();
// Header
lines.push(header_kv("chunk_id", &chunk.chunk_id.0));
lines.push(header_kv("doc_id", &chunk.doc_id.0));
lines.push(header_kv("chunk_id", &chunk.chunk_id.0, theme));
lines.push(header_kv("doc_id", &chunk.doc_id.0, theme));
lines.push(header_kv(
"heading_path",
&if chunk.heading_path.is_empty() {
@@ -205,22 +210,24 @@ pub(crate) fn build_chunk_lines<'a>(
} else {
chunk.heading_path.join(" / ")
},
theme,
));
lines.push(header_kv("chunker_version", &chunk.chunker_version.0));
lines.push(header_kv("policy_hash", &chunk.policy_hash));
lines.push(header_kv("chunker_version", &chunk.chunker_version.0, theme));
lines.push(header_kv("policy_hash", &chunk.policy_hash, theme));
lines.push(header_kv(
"token_estimate",
&chunk.token_estimate.to_string(),
theme,
));
lines.push(blank());
// source spans
push_section_header(&mut lines, SECTION_SPANS, s);
push_section_header(&mut lines, SECTION_SPANS, s, theme);
if !s.collapsed.contains(SECTION_SPANS) {
if chunk.source_spans.is_empty() {
lines.push(Line::from(Span::styled(
" (no spans)",
Style::default().add_modifier(Modifier::DIM),
theme.style(crate::theme::Role::Hint),
)));
} else {
for span in &chunk.source_spans {
@@ -231,7 +238,7 @@ pub(crate) fn build_chunk_lines<'a>(
}
// text
push_section_header(&mut lines, SECTION_TEXT, s);
push_section_header(&mut lines, SECTION_TEXT, s, theme);
if !s.collapsed.contains(SECTION_TEXT) {
for line in chunk.text.lines() {
lines.push(Line::from(format!(" {line}")));
@@ -239,7 +246,7 @@ pub(crate) fn build_chunk_lines<'a>(
if chunk.text.is_empty() {
lines.push(Line::from(Span::styled(
" (empty)",
Style::default().add_modifier(Modifier::DIM),
theme.style(crate::theme::Role::Hint),
)));
}
lines.push(blank());
@@ -252,11 +259,12 @@ pub(crate) fn build_chunk_lines<'a>(
SECTION_EMBEDDINGS,
s,
Some(chunk.block_ids.len()),
theme,
);
if !s.collapsed.contains(SECTION_EMBEDDINGS) {
lines.push(Line::from(Span::styled(
" (embedding records not loaded — out of v1 scope)",
Style::default().add_modifier(Modifier::DIM),
theme.style(crate::theme::Role::Hint),
)));
for bid in &chunk.block_ids {
lines.push(Line::from(format!(" {}", bid.0)));
@@ -265,21 +273,21 @@ pub(crate) fn build_chunk_lines<'a>(
lines
}
fn header_kv(k: &str, v: &str) -> Line<'static> {
fn header_kv(k: &str, v: &str, theme: &crate::theme::Theme) -> Line<'static> {
Line::from(vec![
Span::styled(
format!("{k:>16}: "),
Style::default().add_modifier(Modifier::BOLD).fg(Color::Cyan),
theme.style(crate::theme::Role::Heading),
),
Span::raw(v.to_string()),
])
}
fn kv(k: &str, v: &str) -> Line<'static> {
fn kv(k: &str, v: &str, theme: &crate::theme::Theme) -> Line<'static> {
Line::from(vec![
Span::styled(
format!(" {k}: "),
Style::default().add_modifier(Modifier::DIM),
theme.style(crate::theme::Role::Hint),
),
Span::raw(v.to_string()),
])
@@ -289,8 +297,13 @@ fn blank() -> Line<'static> {
Line::from("")
}
fn push_section_header(lines: &mut Vec<Line<'static>>, name: &'static str, s: &InspectState) {
push_section_header_with_count(lines, name, s, None);
fn push_section_header(
lines: &mut Vec<Line<'static>>,
name: &'static str,
s: &InspectState,
theme: &crate::theme::Theme,
) {
push_section_header_with_count(lines, name, s, None, theme);
}
/// Section header + optional inline count. Inline-count form is used
@@ -301,6 +314,7 @@ fn push_section_header_with_count(
name: &'static str,
s: &InspectState,
count: Option<usize>,
theme: &crate::theme::Theme,
) {
let collapsed = s.collapsed.contains(name);
let marker = if collapsed { "" } else { "" };
@@ -310,9 +324,9 @@ fn push_section_header_with_count(
};
lines.push(Line::from(Span::styled(
title,
Style::default()
.add_modifier(Modifier::BOLD)
.fg(Color::Yellow),
theme
.style(crate::theme::Role::Warning)
.add_modifier(Modifier::BOLD),
)));
}

View File

@@ -22,7 +22,9 @@ mod library;
mod run;
mod search;
mod terminal;
mod theme;
pub use theme::{Palette, Role, Theme};
pub use app::{
App, AskState, IngestState, InspectState, InspectTarget, KeyOutcome, LibraryState, Pane,
SearchState, TERMINAL_LINE_HOLD_SECS,

View File

@@ -11,7 +11,6 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use kebab_core::{DocFilter, DocSummary, Lang};
use ratatui::Frame;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph};
use unicode_width::UnicodeWidthStr;
@@ -111,7 +110,7 @@ pub fn render_library(f: &mut Frame, area: Rect, state: &App) {
.split(area);
if let Some(edit) = &state.library.inner.filter_edit {
render_filter_overlay(f, layout[0], edit);
render_filter_overlay(f, layout[0], edit, &state.theme);
}
render_doc_list(f, layout[1], state);
}
@@ -124,7 +123,7 @@ fn filter_overlay_height(state: &App) -> u16 {
}
}
fn render_filter_overlay(f: &mut Frame, area: Rect, edit: &FilterEdit) {
fn render_filter_overlay(f: &mut Frame, area: Rect, edit: &FilterEdit, theme: &crate::theme::Theme) {
let block = Block::default()
.title("Filter (Tab=cycle field, Enter=apply, Esc=cancel)")
.borders(Borders::ALL);
@@ -132,18 +131,23 @@ fn render_filter_overlay(f: &mut Frame, area: Rect, edit: &FilterEdit) {
f.render_widget(block, area);
let lines = vec![
line_with_focus("tags_any (csv): ", &edit.tags_buf, edit.field == FilterField::Tags),
line_with_focus("lang: ", &edit.lang_buf, edit.field == FilterField::Lang),
line_with_focus("tags_any (csv): ", &edit.tags_buf, edit.field == FilterField::Tags, theme),
line_with_focus("lang: ", &edit.lang_buf, edit.field == FilterField::Lang, theme),
];
let para = Paragraph::new(lines);
f.render_widget(para, inner);
}
fn line_with_focus<'a>(label: &'a str, value: &'a str, focused: bool) -> Line<'a> {
fn line_with_focus<'a>(
label: &'a str,
value: &'a str,
focused: bool,
theme: &crate::theme::Theme,
) -> Line<'a> {
let style = if focused {
Style::default().add_modifier(Modifier::REVERSED)
theme.style(crate::theme::Role::Selected)
} else {
Style::default()
theme.style(crate::theme::Role::Body)
};
Line::from(vec![Span::raw(label), Span::styled(value, style)])
}
@@ -173,7 +177,7 @@ fn render_doc_list(f: &mut Frame, area: Rect, state: &App) {
let list = List::new(items)
.block(block)
.highlight_style(Style::default().add_modifier(Modifier::REVERSED))
.highlight_style(state.theme.style(crate::theme::Role::Selected))
.highlight_symbol("> ");
let mut list_state = inner.list_state.clone();

View File

@@ -5,7 +5,6 @@ use anyhow::Result;
use crossterm::event::{self, Event, KeyEventKind};
use ratatui::Frame;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph};
use std::time::Duration;
@@ -239,7 +238,7 @@ fn render_root(f: &mut Frame, app: &App) {
render_footer(f, outer[2], app);
}
if let Some(err) = &app.error_overlay {
render_error_overlay(f, f.area(), err);
render_error_overlay(f, f.area(), err, &app.theme);
}
}
@@ -249,9 +248,9 @@ fn render_ingest_status(f: &mut Frame, area: Rect, app: &App) {
};
let line = crate::ingest_progress::status_line(state);
let style = if state.aborted {
Style::default().add_modifier(Modifier::BOLD)
app.theme.style(crate::theme::Role::Title)
} else {
Style::default()
app.theme.style(crate::theme::Role::Body)
};
f.render_widget(
Paragraph::new(Line::from(Span::styled(line, style))),
@@ -268,10 +267,7 @@ fn render_header(f: &mut Frame, area: Rect, app: &App) {
Pane::Jobs => "Jobs",
};
let line = Line::from(vec![
Span::styled(
"kebab",
Style::default().add_modifier(Modifier::BOLD),
),
Span::styled("kebab", app.theme.style(crate::theme::Role::Title)),
Span::raw(" / "),
Span::raw(pane_label),
]);
@@ -294,7 +290,7 @@ fn render_footer(f: &mut Frame, area: Rect, app: &App) {
};
let line = Line::from(Span::styled(
hints,
Style::default().add_modifier(Modifier::DIM),
app.theme.style(crate::theme::Role::Hint),
));
f.render_widget(
Paragraph::new(line).block(Block::default().borders(Borders::TOP)),

View File

@@ -19,7 +19,6 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use kebab_core::{Citation, SearchHit, SearchMode, SearchQuery};
use ratatui::Frame;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap};
use std::path::Path;
@@ -60,18 +59,23 @@ pub fn render_search(f: &mut Frame, area: Rect, state: &App) {
])
.split(area);
render_input_bar(f, layout[0], s);
render_result_list(f, layout[1], s);
render_preview(f, layout[2], s);
render_input_bar(f, layout[0], s, &state.theme);
render_result_list(f, layout[1], s, &state.theme);
render_preview(f, layout[2], s, &state.theme);
}
fn render_input_bar(f: &mut Frame, area: Rect, s: &SearchState) {
fn render_input_bar(f: &mut Frame, area: Rect, s: &SearchState, theme: &crate::theme::Theme) {
let mode_label = mode_label(s.mode);
let mode_role = match s.mode {
SearchMode::Lexical => crate::theme::Role::ModeLexical,
SearchMode::Vector => crate::theme::Role::ModeVector,
SearchMode::Hybrid => crate::theme::Role::ModeHybrid,
};
let searching_hint = if s.searching { " searching…" } else { "" };
let line = Line::from(vec![
Span::styled(format!("[{mode_label}] "), Style::default().fg(Color::Cyan)),
Span::styled(format!("[{mode_label}] "), theme.style(mode_role)),
Span::raw(s.input.as_str()),
Span::styled(searching_hint, Style::default().add_modifier(Modifier::DIM)),
Span::styled(searching_hint, theme.style(crate::theme::Role::Hint)),
]);
let block = Block::default()
.title("query (Tab=mode Enter=search Esc=back)")
@@ -87,7 +91,7 @@ fn mode_label(m: SearchMode) -> &'static str {
}
}
fn render_result_list(f: &mut Frame, area: Rect, s: &SearchState) {
fn render_result_list(f: &mut Frame, area: Rect, s: &SearchState, theme: &crate::theme::Theme) {
let block = Block::default()
.title(format!("results ({})", s.hits.len()))
.borders(Borders::ALL);
@@ -100,11 +104,11 @@ fn render_result_list(f: &mut Frame, area: Rect, s: &SearchState) {
let items: Vec<ListItem> = s
.hits
.iter()
.map(|h| ListItem::new(format_hit_lines(h)))
.map(|h| ListItem::new(format_hit_lines(h, theme)))
.collect();
let list = List::new(items)
.block(block)
.highlight_style(Style::default().add_modifier(Modifier::REVERSED))
.highlight_style(theme.style(crate::theme::Role::Selected))
.highlight_symbol("> ");
let mut list_state = ListState::default();
list_state.select(Some(s.selected_hit.min(s.hits.len().saturating_sub(1))));
@@ -116,7 +120,7 @@ fn render_result_list(f: &mut Frame, area: Rect, s: &SearchState) {
/// 2. `<heading_path joined by " / "> | section_label?`
/// 3. snippet line 1
/// 4. snippet line 2 (or trailing blank for layout symmetry)
fn format_hit_lines(h: &SearchHit) -> Vec<Line<'static>> {
fn format_hit_lines(h: &SearchHit, theme: &crate::theme::Theme) -> Vec<Line<'static>> {
let header = format!(
"{}. {:.4} {}",
h.rank,
@@ -138,17 +142,14 @@ fn format_hit_lines(h: &SearchHit) -> Vec<Line<'static>> {
let s1 = snippet_lines.next().unwrap_or("").to_string();
let s2 = snippet_lines.next().unwrap_or("").to_string();
vec![
Line::from(Span::styled(
header,
Style::default().add_modifier(Modifier::BOLD),
)),
Line::from(Span::styled(path_line, Style::default().fg(Color::DarkGray))),
Line::from(Span::styled(header, theme.style(crate::theme::Role::Title))),
Line::from(Span::styled(path_line, theme.style(crate::theme::Role::Path))),
Line::from(format!(" {s1}")),
Line::from(format!(" {s2}")),
]
}
fn render_preview(f: &mut Frame, area: Rect, s: &SearchState) {
fn render_preview(f: &mut Frame, area: Rect, s: &SearchState, theme: &crate::theme::Theme) {
let block = Block::default()
.title("preview (g=open in $EDITOR)")
.borders(Borders::ALL);
@@ -157,7 +158,7 @@ fn render_preview(f: &mut Frame, area: Rect, s: &SearchState) {
(Some(text), _) => Paragraph::new(text.as_str()).wrap(Wrap { trim: false }),
(None, _) => Paragraph::new(Span::styled(
"(loading preview… select a hit to fetch its chunk text)",
Style::default().add_modifier(Modifier::DIM),
theme.style(crate::theme::Role::Hint),
)),
};
f.render_widget(body.block(block), area);

View File

@@ -0,0 +1,276 @@
//! p9-fb-14: TUI palette + role-style mapping.
//!
//! Every pane (`library`, `search`, `ask`, `inspect`, `error_popup`,
//! the `run::render_root` shell) routes its `ratatui::style::Style`
//! through `Theme::style(role)` instead of inlining
//! `Style::default().fg(...)`. Adding a new role here is the only
//! place a color decision needs to land — accidental drift between
//! panes (`Cyan` for one badge, `LightCyan` for another) becomes a
//! single-file diff.
//!
//! ## Why role-based, not "style table"
//!
//! Earlier sketches keyed a hashmap by role at runtime. A `match`
//! against an enum is faster (no allocation, no hashing), exhaustive
//! at compile time (forgetting a role for `Theme::light` is a
//! compile error if you `match` exhaustively on `Role` in the
//! palette body — and we do), and lets `Theme::style` return
//! `Style` by value without lifetimes.
//!
//! ## Accessibility
//!
//! Color is never the *only* signal — the score badge ships
//! `[score=0.92]` text alongside its color, the mode badge ships
//! `[Hybrid]` text, the refusal renders the literal `(refused)`
//! prefix. The theme just amplifies signals that the text already
//! carries.
use ratatui::style::{Color, Modifier, Style};
/// Role-style enumeration. Adding a variant requires updating both
/// `dark_style` and `light_style` (the compiler enforces it via the
/// exhaustive `match` in each palette).
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
pub enum Role {
/// Active pane border (the focused one).
BorderActive,
/// Inactive pane border.
BorderInactive,
/// Document/section title (bold, accent color).
Title,
/// Secondary path / subpath (dim).
Path,
/// Lexical search-mode badge.
ModeLexical,
/// Vector search-mode badge.
ModeVector,
/// Hybrid search-mode badge.
ModeHybrid,
/// Selected row in any list (search hits, library docs, …).
Selected,
/// Dim hint / placeholder text (mode line subtext, "loading…").
Hint,
/// Section heading (bold + accent — Inspect uses this).
Heading,
/// Warning yellow — refusals, malformed-frontmatter notices.
Warning,
/// Error red — error overlays, "spawn failed" lines.
Error,
/// Success green — completed ingest, grounded answer.
Success,
/// Citation marker (`[1]`, `[2]`) and citation link text.
CitationMarker,
/// Bullet glyph in list rendering.
Bullet,
/// Default body text (no decoration). Returned as
/// `Style::default()` in both palettes — kept as a Role so
/// callers don't sprinkle `Style::default()` directly.
Body,
}
/// Palette identity. `Theme` carries this so panes can branch on
/// "is dark" if they need a different glyph (rarely needed since
/// roles already abstract the color), but in practice the
/// `Theme::style` dispatcher is the only consumer.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Palette {
Dark,
Light,
}
#[derive(Clone, Debug)]
pub struct Theme {
palette: Palette,
}
impl Theme {
/// Default dark palette — intended for the typical terminal
/// (white-on-black scheme). Distinct from `Theme::light`.
pub fn dark() -> Self {
Self {
palette: Palette::Dark,
}
}
/// Light palette — intended for users running a light-background
/// terminal scheme. Hues stay the same; brightness shifts so the
/// foreground stays readable on white.
pub fn light() -> Self {
Self {
palette: Palette::Light,
}
}
/// Resolve a config string ("dark" / "light", case-insensitive)
/// to a `Theme`. Unknown values fall back to dark — never errors.
/// p9-fb-14 spec: "config never errors on a typo, the TUI just
/// keeps the default theme so the user has a working shell."
pub fn from_name(s: &str) -> Self {
match s.trim().to_ascii_lowercase().as_str() {
"light" => Self::light(),
_ => Self::dark(),
}
}
/// The underlying palette identity. Mostly a debugging aid.
pub fn palette(&self) -> Palette {
self.palette
}
/// Resolve a `Role` to a `Style`. Both palettes implement every
/// role exhaustively (compile error if a variant is added but
/// the palette body forgets it).
pub fn style(&self, role: Role) -> Style {
match self.palette {
Palette::Dark => dark_style(role),
Palette::Light => light_style(role),
}
}
}
impl Default for Theme {
fn default() -> Self {
Self::dark()
}
}
/// Dark palette — high-contrast on black. The exhaustive match
/// guarantees adding a `Role` variant here forces the same in
/// `light_style`.
fn dark_style(role: Role) -> Style {
match role {
Role::BorderActive => Style::default().fg(Color::Cyan),
Role::BorderInactive => Style::default().fg(Color::DarkGray),
Role::Title => Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
Role::Path => Style::default().fg(Color::DarkGray),
Role::ModeLexical => Style::default().fg(Color::Yellow),
Role::ModeVector => Style::default().fg(Color::Magenta),
Role::ModeHybrid => Style::default().fg(Color::Cyan),
Role::Selected => Style::default().add_modifier(Modifier::REVERSED),
Role::Hint => Style::default().add_modifier(Modifier::DIM),
Role::Heading => Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
Role::Warning => Style::default().fg(Color::Yellow),
Role::Error => Style::default().fg(Color::Red),
Role::Success => Style::default().fg(Color::Green),
Role::CitationMarker => Style::default().fg(Color::Cyan),
Role::Bullet => Style::default().fg(Color::DarkGray),
Role::Body => Style::default(),
}
}
/// Light palette — high-contrast on white. Same hues as dark
/// (so user mental-models transfer) but with darker variants where
/// `Color::*` differs in 16-color terminals (e.g., `LightYellow`
/// would wash out on white, so `Yellow` stays).
fn light_style(role: Role) -> Style {
match role {
Role::BorderActive => Style::default().fg(Color::Blue),
Role::BorderInactive => Style::default().fg(Color::Gray),
Role::Title => Style::default()
.fg(Color::Black)
.add_modifier(Modifier::BOLD),
Role::Path => Style::default().fg(Color::Gray),
Role::ModeLexical => Style::default().fg(Color::Yellow),
Role::ModeVector => Style::default().fg(Color::Magenta),
Role::ModeHybrid => Style::default().fg(Color::Blue),
Role::Selected => Style::default().add_modifier(Modifier::REVERSED),
Role::Hint => Style::default().add_modifier(Modifier::DIM),
Role::Heading => Style::default()
.fg(Color::Blue)
.add_modifier(Modifier::BOLD),
Role::Warning => Style::default().fg(Color::Yellow),
Role::Error => Style::default().fg(Color::Red),
Role::Success => Style::default().fg(Color::Green),
Role::CitationMarker => Style::default().fg(Color::Blue),
Role::Bullet => Style::default().fg(Color::Gray),
Role::Body => Style::default(),
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Both palettes resolve every `Role` to a `Style` (no panic /
/// no `unreachable!()` branch). The exhaustive match in
/// `dark_style` / `light_style` makes this true at compile
/// time, but we exercise it at runtime so a regression to
/// `match _ => unreachable!()` would surface in test instead
/// of in production.
#[test]
fn every_role_resolves_in_dark_and_light() {
let roles = [
Role::BorderActive,
Role::BorderInactive,
Role::Title,
Role::Path,
Role::ModeLexical,
Role::ModeVector,
Role::ModeHybrid,
Role::Selected,
Role::Hint,
Role::Heading,
Role::Warning,
Role::Error,
Role::Success,
Role::CitationMarker,
Role::Bullet,
Role::Body,
];
for r in roles {
let _ = Theme::dark().style(r);
let _ = Theme::light().style(r);
}
}
/// `Theme::from_name` recognizes exactly two palette names; any
/// other input falls back to dark. Pinned per spec: "config
/// never errors on a typo".
#[test]
fn from_name_recognizes_dark_light_and_falls_back() {
assert_eq!(Theme::from_name("dark").palette(), Palette::Dark);
assert_eq!(Theme::from_name("DARK").palette(), Palette::Dark);
assert_eq!(Theme::from_name(" dark ").palette(), Palette::Dark);
assert_eq!(Theme::from_name("light").palette(), Palette::Light);
assert_eq!(Theme::from_name("LIGHT").palette(), Palette::Light);
assert_eq!(Theme::from_name("solarized").palette(), Palette::Dark);
assert_eq!(Theme::from_name("").palette(), Palette::Dark);
}
/// `Theme::default()` is dark — pinned so the default doesn't
/// silently flip in a future refactor.
#[test]
fn default_palette_is_dark() {
assert_eq!(Theme::default().palette(), Palette::Dark);
}
/// Critical roles emit `Style` with at least one decoration —
/// catches regressions where someone replaces a styled palette
/// branch with a bare `Style::default()`. `Body` is excluded
/// (it intentionally returns the default).
#[test]
fn primary_roles_carry_decoration_in_dark() {
let theme = Theme::dark();
for r in [
Role::Title,
Role::Selected,
Role::Heading,
Role::Error,
Role::Warning,
Role::Success,
] {
let style = theme.style(r);
let has_color = style.fg.is_some() || style.bg.is_some();
let has_modifier = !style.add_modifier.is_empty();
assert!(
has_color || has_modifier,
"role {:?} resolves to bare Style::default() in dark palette",
r
);
}
}
}

View File

@@ -108,6 +108,9 @@ prompt_template_version = "rag-v1"
score_gate = 0.05 # RRF 정규화 후 [0, 1] 범위라 default 그대로 OK
explain_default = false
max_context_tokens = 6000
[ui]
theme = "dark" # p9-fb-14 — TUI palette ("dark" / "light", default "dark")
```
`KEBAB_*` 환경변수로 override 가능 (`KEBAB_MODELS_LLM_MODEL=gemma4:26b kebab …` 등). 자세한 키 목록은 `crates/kebab-config/src/lib.rs``apply_env` 매치 암.

View File

@@ -3,7 +3,7 @@ phase: P9
component: kebab-tui
task_id: p9-fb-14
title: "TUI color theme module (role-based + dark/light toggle)"
status: planned
status: in_progress
depends_on: []
unblocks: [p9-fb-11]
contract_source: ../../docs/superpowers/specs/2026-04-27-kebab-final-form-design.md