core/ui: 抽出语义化 console 主题, 调用方去硬编码颜色

新增 core/ui.py 集中定义 Rich Theme:
- 语义样式名: user / assistant / tool / ok / warn / err / info / muted / accent
- 在黑底终端上 readable, 弱化用 grey 而非 dim, 强调走 bright_*
- make_console() 统一应用主题, 以后改主题只动这一处

cli.py / main.py / core/loop.py 把内联的 [red] [green] [blue] [yellow]
[cyan] [dim] 等替换为语义样式; 调用 make_console() 取代 Console()。
This commit is contained in:
caoqianming 2026-05-07 16:10:11 +08:00
parent 647d92f532
commit 72d2b64c40
4 changed files with 85 additions and 46 deletions

74
cli.py
View File

@ -15,11 +15,11 @@ import json
import sys import sys
import click import click
from rich.console import Console
from rich.prompt import Prompt from rich.prompt import Prompt
from rich.table import Table from rich.table import Table
from core.task import TaskState from core.task import TaskState
from core.ui import make_console
from main import ( from main import (
ROOT, ROOT,
build_agent, build_agent,
@ -43,7 +43,7 @@ def cli() -> None:
@click.option("--desc", default="", help="一句话任务描述,便于 tasks 列表识别") @click.option("--desc", default="", help="一句话任务描述,便于 tasks 列表识别")
def chat(model: str, workspace: str, resume: str, mode: str, desc: str) -> None: def chat(model: str, workspace: str, resume: str, mode: str, desc: str) -> None:
"""启动交互式 REPL。每次启动默认开新 task,用 --resume 接老的。""" """启动交互式 REPL。每次启动默认开新 task,用 --resume 接老的。"""
console = Console() console = make_console()
try: try:
agent, session, sid, task_state, task_dir = build_agent( agent, session, sid, task_state, task_dir = build_agent(
model_name=model, model_name=model,
@ -55,32 +55,32 @@ def chat(model: str, workspace: str, resume: str, mode: str, desc: str) -> None:
description=desc, description=desc,
) )
except Exception as e: except Exception as e:
console.print(f"[red]启动失败:[/red] {type(e).__name__}: {e}") console.print(f"[err]启动失败:[/err] {type(e).__name__}: {e}")
sys.exit(1) sys.exit(1)
if resume: if resume:
console.print( console.print(
f"[green]恢复 task[/green] [bold]{sid}[/bold] ({len(session.messages)} 条消息) " f"[ok]恢复 task[/ok] [bold]{sid}[/bold] ({len(session.messages)} 条消息) "
f"model: [bold]{agent.caps.model_id}[/bold]" f"model: [accent]{agent.caps.model_id}[/accent]"
) )
else: else:
meta_tail = "" meta_tail = ""
if task_state.mode or task_state.description: if task_state.mode or task_state.description:
meta_tail = f" mode={task_state.mode!r} desc={task_state.description!r}" meta_tail = f" mode={task_state.mode!r} desc={task_state.description!r}"
console.print( console.print(
f"[green]新 task[/green] [bold]{sid}[/bold] " f"[ok]新 task[/ok] [bold]{sid}[/bold] "
f"model: [bold]{agent.caps.model_id}[/bold]{meta_tail}" f"model: [accent]{agent.caps.model_id}[/accent]{meta_tail}"
) )
console.print( console.print(
"[dim]/exit 退出 /reset 清空对话(保留 task) /new 开新 task /id /status 查看 " "[info]/exit 退出 /reset 清空对话(保留 task) /new 开新 task /id /status 查看 "
"/done /abandon 改状态 /desc <文本> 设描述[/dim]\n" "/done /abandon 改状态 /desc <文本> 设描述[/info]\n"
) )
while True: while True:
try: try:
user_input = Prompt.ask("[bold blue]you[/bold blue]") user_input = Prompt.ask("[user]you[/user]", console=console)
except (EOFError, KeyboardInterrupt): except (EOFError, KeyboardInterrupt):
console.print("\n[dim]bye[/dim]") console.print("\n[muted]bye[/muted]")
break break
cmd = user_input.strip() cmd = user_input.strip()
@ -88,7 +88,7 @@ def chat(model: str, workspace: str, resume: str, mode: str, desc: str) -> None:
break break
if cmd == "/reset": if cmd == "/reset":
session.reset(keep_system=True) session.reset(keep_system=True)
console.print("[dim]当前 task 对话已重置(保留 system 和 state)[/dim]") console.print("[info]当前 task 对话已重置(保留 system 和 state)[/info]")
continue continue
if cmd == "/new": if cmd == "/new":
try: try:
@ -97,39 +97,39 @@ def chat(model: str, workspace: str, resume: str, mode: str, desc: str) -> None:
mode=mode, description=desc, mode=mode, description=desc,
) )
except Exception as e: except Exception as e:
console.print(f"[red]新建失败:[/red] {type(e).__name__}: {e}") console.print(f"[err]新建失败:[/err] {type(e).__name__}: {e}")
continue continue
console.print(f"[green]新 task[/green] [bold]{sid}[/bold]") console.print(f"[ok]新 task[/ok] [bold]{sid}[/bold]")
continue continue
if cmd == "/id": if cmd == "/id":
cwd_disp = session.meta.get("cwd", "?") cwd_disp = session.meta.get("cwd", "?")
model_disp = session.meta.get("model", agent.caps.model_id) model_disp = session.meta.get("model", agent.caps.model_id)
console.print(f"[dim]task: {sid} model: {model_disp} cwd: {cwd_disp}[/dim]") console.print(f"[info]task: {sid} model: {model_disp} cwd: {cwd_disp}[/info]")
continue continue
if cmd == "/status": if cmd == "/status":
console.print( console.print(
f"[dim]task {task_state.task_id} status={task_state.status} " f"[info]task {task_state.task_id} status={task_state.status} "
f"mode={task_state.mode!r} desc={task_state.description!r}\n" f"mode={task_state.mode!r} desc={task_state.description!r}\n"
f" model={task_state.model} tokens={task_state.tokens_total} " f" model={task_state.model} tokens={task_state.tokens_total} "
f"(p={task_state.tokens_prompt}/c={task_state.tokens_completion}) " f"(p={task_state.tokens_prompt}/c={task_state.tokens_completion}) "
f"created={task_state.created_at} updated={task_state.updated_at}[/dim]" f"created={task_state.created_at} updated={task_state.updated_at}[/info]"
) )
continue continue
if cmd == "/done": if cmd == "/done":
task_state.status = "completed" task_state.status = "completed"
task_state.save(task_dir) task_state.save(task_dir)
console.print(f"[green]task {sid} marked completed[/green]") console.print(f"[ok]task {sid} marked completed[/ok]")
break break
if cmd == "/abandon": if cmd == "/abandon":
task_state.status = "abandoned" task_state.status = "abandoned"
task_state.save(task_dir) task_state.save(task_dir)
console.print(f"[yellow]task {sid} marked abandoned[/yellow]") console.print(f"[warn]task {sid} marked abandoned[/warn]")
break break
if cmd.startswith("/desc"): if cmd.startswith("/desc"):
new_desc = cmd[len("/desc"):].strip() new_desc = cmd[len("/desc"):].strip()
task_state.description = new_desc task_state.description = new_desc
task_state.save(task_dir) task_state.save(task_dir)
console.print(f"[dim]description set: {new_desc!r}[/dim]") console.print(f"[info]description set: {new_desc!r}[/info]")
continue continue
if not cmd: if not cmd:
continue continue
@ -137,9 +137,9 @@ def chat(model: str, workspace: str, resume: str, mode: str, desc: str) -> None:
try: try:
agent.run(user_input) agent.run(user_input)
except KeyboardInterrupt: except KeyboardInterrupt:
console.print("\n[yellow]已中断本轮。下一条输入会继续这个 task。[/yellow]") console.print("\n[warn]已中断本轮。下一条输入会继续这个 task。[/warn]")
except Exception as e: except Exception as e:
console.print(f"[red]运行错误:[/red] {type(e).__name__}: {e}") console.print(f"[err]运行错误:[/err] {type(e).__name__}: {e}")
finally: finally:
sync_task_tokens(task_state, task_dir, agent.llm) sync_task_tokens(task_state, task_dir, agent.llm)
@ -189,12 +189,12 @@ def tasks(workspace: str, limit: int, status: str) -> None:
tbl.add_column("msgs", justify="right") tbl.add_column("msgs", justify="right")
tbl.add_column("tokens", justify="right") tbl.add_column("tokens", justify="right")
tbl.add_column("desc") tbl.add_column("desc")
sc = {"active": "cyan", "completed": "green", "abandoned": "dim"} sc = {"active": "status.active", "completed": "status.completed", "abandoned": "status.abandoned"}
for _, tid, st, mode, model, tok, n, desc in rows: for _, tid, st, mode, model, tok, n, desc in rows:
c = sc.get(st, "white") c = sc.get(st, "info")
d_show = desc if len(desc) <= 50 else desc[:47] + "..." d_show = desc if len(desc) <= 50 else desc[:47] + "..."
tbl.add_row(tid, f"[{c}]{st}[/{c}]", mode, model, str(n), str(tok), d_show) tbl.add_row(tid, f"[{c}]{st}[/{c}]", mode, model, str(n), str(tok), d_show)
Console().print(tbl) make_console().print(tbl)
@cli.command() @cli.command()
@ -209,25 +209,25 @@ def probe(model: str, long_context: bool) -> None:
cfg = load_config() cfg = load_config()
name = model or cfg["default_model"] name = model or cfg["default_model"]
console = Console() console = make_console()
try: try:
caps = ModelCapabilities.load(name, ROOT / cfg["models_dir"]) caps = ModelCapabilities.load(name, ROOT / cfg["models_dir"])
except Exception as e: except Exception as e:
console.print(f"[red]档案加载失败:[/red] {type(e).__name__}: {e}") console.print(f"[err]档案加载失败:[/err] {type(e).__name__}: {e}")
sys.exit(1) sys.exit(1)
console.print( console.print(
f"[bold]probing[/bold] [cyan]{caps.model_id}[/cyan] (profile: {name}) " f"[bold]probing[/bold] [accent]{caps.model_id}[/accent] (profile: {name}) "
f"[dim]long-context={long_context}[/dim]\n" f"[muted]long-context={long_context}[/muted]\n"
) )
try: try:
llm = LLM(caps) llm = LLM(caps)
except Exception as e: except Exception as e:
console.print(f"[red]LLM 构造失败:[/red] {type(e).__name__}: {e}") console.print(f"[err]LLM 构造失败:[/err] {type(e).__name__}: {e}")
sys.exit(1) sys.exit(1)
with console.status("[dim]running probes...[/dim]", spinner="dots"): with console.status("[muted]running probes...[/muted]", spinner="dots"):
report = probe_capabilities(caps, llm, include_long_context=long_context) report = probe_capabilities(caps, llm, include_long_context=long_context)
tbl = Table(show_lines=False) tbl = Table(show_lines=False)
@ -236,9 +236,9 @@ def probe(model: str, long_context: bool) -> None:
tbl.add_column("observed") tbl.add_column("observed")
tbl.add_column("status") tbl.add_column("status")
tbl.add_column("detail") tbl.add_column("detail")
color = {"ok": "green", "mismatch": "yellow", "error": "red", "skip": "dim"} color = {"ok": "ok", "mismatch": "warn", "error": "err", "skip": "muted"}
for r in report.results: for r in report.results:
c = color.get(r.status, "white") c = color.get(r.status, "info")
tbl.add_row( tbl.add_row(
r.name, r.name,
str(r.declared), str(r.declared),
@ -250,14 +250,14 @@ def probe(model: str, long_context: bool) -> None:
if report.has_mismatch: if report.has_mismatch:
console.print( console.print(
"\n[yellow]存在能力对账差异 —— 看 detail,必要时改 " "\n[warn]存在能力对账差异 —— 看 detail,必要时改 "
f"config/models/{caps.family}.yaml[/yellow]" f"config/models/{caps.family}.yaml[/warn]"
) )
sys.exit(2) sys.exit(2)
if any(r.status == "error" for r in report.results): if any(r.status == "error" for r in report.results):
console.print("\n[red]部分探测出错(见 detail)[/red]") console.print("\n[err]部分探测出错(见 detail)[/err]")
sys.exit(3) sys.exit(3)
console.print("\n[green]全部能力声明与实测一致。[/green]") console.print("\n[ok]全部能力声明与实测一致。[/ok]")
if __name__ == "__main__": if __name__ == "__main__":

View File

@ -13,6 +13,7 @@ from rich.markdown import Markdown
from .capabilities import ModelCapabilities from .capabilities import ModelCapabilities
from .llm import LLM from .llm import LLM
from .session import Session from .session import Session
from .ui import make_console
def _extract_usage(usage: Any) -> Tuple[int, int]: def _extract_usage(usage: Any) -> Tuple[int, int]:
@ -43,7 +44,7 @@ class AgentLoop:
self.session = session self.session = session
self.caps = capabilities self.caps = capabilities
self.max_iterations = max_iterations or capabilities.max_iterations self.max_iterations = max_iterations or capabilities.max_iterations
self.console = console or Console() self.console = console or make_console()
@contextmanager @contextmanager
def _thinking(self): def _thinking(self):
@ -55,7 +56,7 @@ class AgentLoop:
elapsed = time.monotonic() - start elapsed = time.monotonic() - start
total = self.llm.token_counter.total total = self.llm.token_counter.total
tail = f" ctx {total:,} tok" if total else "" tail = f" ctx {total:,} tok" if total else ""
return f"[dim]thinking... {elapsed:.1f}s{tail}[/dim]" return f"[muted]thinking... {elapsed:.1f}s{tail}[/muted]"
class Ctx: class Ctx:
elapsed: float = 0.0 elapsed: float = 0.0
@ -95,13 +96,13 @@ class AgentLoop:
pt, ct = _extract_usage(getattr(response, "usage", None)) pt, ct = _extract_usage(getattr(response, "usage", None))
self.console.print( self.console.print(
f"[dim][in {pt:,} out {ct:,} t {t.elapsed:.1f}s][/dim]" f"[info][in {pt:,} out {ct:,} t {t.elapsed:.1f}s][/info]"
) )
tool_calls = getattr(msg, "tool_calls", None) or [] tool_calls = getattr(msg, "tool_calls", None) or []
content = getattr(msg, "content", None) content = getattr(msg, "content", None)
if content: if content:
self.console.print("[cyan]assistant>[/cyan]") self.console.print("[assistant]assistant>[/assistant]")
self.console.print(Markdown(content)) self.console.print(Markdown(content))
if not tool_calls: if not tool_calls:
@ -130,7 +131,7 @@ class AgentLoop:
preview = json.dumps(args, ensure_ascii=False) preview = json.dumps(args, ensure_ascii=False)
if len(preview) > 200: if len(preview) > 200:
preview = preview[:200] + "..." preview = preview[:200] + "..."
self.console.print(f"[yellow]tool>[/yellow] {name}({preview})") self.console.print(f"[tool]tool>[/tool] {name}({preview})")
tool = self.tools.get(name) tool = self.tools.get(name)
if tool is None: if tool is None:
@ -153,5 +154,5 @@ class AgentLoop:
# 给用户预览(截短) # 给用户预览(截短)
preview = result if len(result) < 400 else result[:400] + "..." preview = result if len(result) < 400 else result[:400] + "..."
self.console.print(f"[dim]{preview}[/dim]") self.console.print(f"[muted]{preview}[/muted]")
return result return result

38
core/ui.py Normal file
View File

@ -0,0 +1,38 @@
"""Rich 控制台主题: 统一 dark 终端友好的配色。
设计目标: 在黑底终端上,所有文字 readable,且语义有层次
- 弱化用 grey50/grey70 而不是 dim 一些终端把 dim 渲染得过暗
- 强调色一律走 bright_* 8 色版的 blue/cyan Windows 深底太黯
- 用语义样式名(user / assistant / tool / ok / warn / err / info / muted),
避免在调用处硬编码颜色,以后改主题只动这一处
"""
from __future__ import annotations
from rich.console import Console
from rich.theme import Theme
ZCBOT_THEME = Theme(
{
# 通用层次
"info": "grey70", # 普通信息(系统反馈、路径、命令提示)
"muted": "grey50", # 真要弱化的次要内容(预览、spinner 文本)
"accent": "bright_cyan", # 强调(模型名等)
# 角色
"user": "bold bright_magenta",
"assistant": "bold bright_cyan",
"tool": "bold bright_yellow",
# 状态
"ok": "bright_green",
"warn": "bright_yellow",
"err": "bold bright_red",
# task 状态列
"status.active": "bright_cyan",
"status.completed": "bright_green",
"status.abandoned": "grey50",
}
)
def make_console() -> Console:
"""创建带 zcbot 主题的 Console。所有 TUI 入口都应通过这个工厂拿 console。"""
return Console(theme=ZCBOT_THEME)

View File

@ -103,10 +103,10 @@ def build_agent(
saved_cwd = session.meta.get("cwd") saved_cwd = session.meta.get("cwd")
if saved_cwd and console is not None and saved_cwd != str(tool_base): if saved_cwd and console is not None and saved_cwd != str(tool_base):
console.print( console.print(
f"[yellow]提示:[/yellow] 当前 cwd 与 task 记录不同 —— " f"[warn]提示:[/warn] 当前 cwd 与 task 记录不同 —— "
f"工具基于 current cwd,不会自动切回。\n" f"工具基于 current cwd,不会自动切回。\n"
f" task cwd: [dim]{saved_cwd}[/dim]\n" f" task cwd: [info]{saved_cwd}[/info]\n"
f" current cwd: [dim]{tool_base}[/dim]" f" current cwd: [info]{tool_base}[/info]"
) )
task_state = TaskState.load(task_dir) task_state = TaskState.load(task_dir)
if task_state is None: if task_state is None: