Merge pull request 'feat(kebab-tui): p9-fb-14 color theme module — role-based palette' (#70) from feat/p9-fb-14-theme into main
This commit was merged in pull request #70.
This commit is contained in:
@@ -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 후보
|
||||
|
||||
|
||||
@@ -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/`.
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,34 @@ 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)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
// `Role::Heading` already includes BOLD in both palettes, so
|
||||
// no need to `add_modifier(BOLD)` here — the redundancy would
|
||||
// imply Heading lacks BOLD elsewhere.
|
||||
Span::styled(q_label, theme.style(crate::theme::Role::Heading)),
|
||||
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 +182,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 +226,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 +246,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()),
|
||||
])
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
)));
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,10 +247,16 @@ fn render_ingest_status(f: &mut Frame, area: Rect, app: &App) {
|
||||
return;
|
||||
};
|
||||
let line = crate::ingest_progress::status_line(state);
|
||||
// p9-fb-14: `aborted` is a non-fatal-but-noteworthy state (Ctrl-C
|
||||
// partial commit) — `Role::Warning` (yellow) is the right semantic
|
||||
// signal, plus an explicit BOLD so the abort line still stands
|
||||
// out from the live progress lines around it.
|
||||
let style = if state.aborted {
|
||||
Style::default().add_modifier(Modifier::BOLD)
|
||||
app.theme
|
||||
.style(crate::theme::Role::Warning)
|
||||
.add_modifier(ratatui::style::Modifier::BOLD)
|
||||
} else {
|
||||
Style::default()
|
||||
app.theme.style(crate::theme::Role::Body)
|
||||
};
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(Span::styled(line, style))),
|
||||
@@ -268,10 +273,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 +296,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)),
|
||||
|
||||
@@ -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);
|
||||
|
||||
280
crates/kebab-tui/src/theme.rs
Normal file
280
crates/kebab-tui/src/theme.rs
Normal file
@@ -0,0 +1,280 @@
|
||||
//! 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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `Theme::default() == Theme::dark()` — pinned by
|
||||
/// `default_palette_is_dark` test. If the default ever flips, both
|
||||
/// the test and downstream callers (e.g. integration smokes that
|
||||
/// rely on dark contrast) need a coordinated update.
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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` 매치 암.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user