Merge branch 'tui-system-message-render' into develop

This commit is contained in:
Keisuke Hirata 2026-05-04 12:10:17 +09:00
commit a0771608b1
10 changed files with 422 additions and 186 deletions

View File

@ -14,7 +14,6 @@
- Run 中の入力キューイング → [tickets/tui-input-queue.md](tickets/tui-input-queue.md)
- ユーザーマニフェストのモデル設定 wizard → [tickets/tui-user-model-setup.md](tickets/tui-user-model-setup.md)
- spawn 失敗時に Pod の stderr が TUI に表示されない → [tickets/tui-spawn-error-surface.md](tickets/tui-spawn-error-surface.md)
- role:system の system message を TUI に表示する仕組み → [tickets/tui-system-message-render.md](tickets/tui-system-message-render.md)
- 巻き戻されたターンの入力テキストを編集領域に復元 → [tickets/tui-empty-turn-restore.md](tickets/tui-empty-turn-restore.md)
- Manifest: Tool Output / File Upload 上限の分離とデフォルト緩和 → [tickets/manifest-output-upload-limits.md](tickets/manifest-output-upload-limits.md)
- メモリ機構

View File

@ -168,6 +168,10 @@ pub struct Worker<C: LlmClient, S: WorkerState = Mutable> {
/// truncation have been applied — i.e. on the same data that
/// enters history.
tool_result_cbs: Vec<Box<dyn Fn(&ToolResult) + Send + Sync>>,
/// History-append callbacks. Invoked for non-streamed items when they
/// are appended to persistent worker history, so upper layers can
/// broadcast those items using history itself as the source of truth.
history_append_cbs: Vec<Box<dyn Fn(&Item) + Send + Sync>>,
/// Request configuration (max_tokens, temperature, etc.)
request_config: RequestConfig,
/// Whether the previous run was interrupted
@ -346,6 +350,25 @@ impl<C: LlmClient, S: WorkerState> Worker<C, S> {
}
}
/// Register a callback invoked for items appended directly to worker
/// history outside streaming timeline callbacks.
pub fn on_history_append(&mut self, callback: impl Fn(&Item) + Send + Sync + 'static) {
self.history_append_cbs.push(Box::new(callback));
}
fn emit_history_append(&self, item: &Item) {
for cb in &self.history_append_cbs {
cb(item);
}
}
fn extend_history_with_callbacks(&mut self, items: impl IntoIterator<Item = Item>) {
for item in items {
self.emit_history_append(&item);
self.history.push(item);
}
}
/// Register a turn-end callback (receives 0-based turn number).
pub fn on_turn_end(&mut self, callback: impl Fn(usize) + Send + Sync + 'static) {
self.turn_end_cbs.push(Box::new(callback));
@ -863,7 +886,7 @@ impl<C: LlmClient, S: WorkerState> Worker<C, S> {
// get persisted by the upper layer that owns history.json.
let pending = self.interceptor.pending_history_appends().await;
if !pending.is_empty() {
self.history.extend(pending);
self.extend_history_with_callbacks(pending);
}
// Clone the history into a per-request context. Everything
@ -962,7 +985,7 @@ impl<C: LlmClient, S: WorkerState> Worker<C, S> {
return Ok(WorkerResult::Finished);
}
TurnEndAction::ContinueWithMessages(additional) => {
self.history.extend(additional);
self.extend_history_with_callbacks(additional);
continue;
}
TurnEndAction::Pause => {
@ -1118,6 +1141,7 @@ impl<C: LlmClient> Worker<C, Mutable> {
turn_end_cbs: Vec::new(),
warning_cbs: Vec::new(),
tool_result_cbs: Vec::new(),
history_append_cbs: Vec::new(),
request_config: RequestConfig::default(),
last_run_interrupted: false,
cancel_tx,
@ -1375,6 +1399,7 @@ impl<C: LlmClient> Worker<C, Mutable> {
turn_end_cbs: self.turn_end_cbs,
warning_cbs: self.warning_cbs,
tool_result_cbs: self.tool_result_cbs,
history_append_cbs: self.history_append_cbs,
request_config: self.request_config,
last_run_interrupted: self.last_run_interrupted,
@ -1415,7 +1440,7 @@ impl<C: LlmClient> Worker<C, Locked> {
};
self.history.push(user_item);
if !extras.is_empty() {
self.history.extend(extras);
self.extend_history_with_callbacks(extras);
}
let result = self.run_turn_loop().await;
self.finalize_interruption(result).await
@ -1456,6 +1481,7 @@ impl<C: LlmClient> Worker<C, Locked> {
turn_end_cbs: self.turn_end_cbs,
warning_cbs: self.warning_cbs,
tool_result_cbs: self.tool_result_cbs,
history_append_cbs: self.history_append_cbs,
request_config: self.request_config,
last_run_interrupted: self.last_run_interrupted,

View File

@ -3,6 +3,7 @@ use std::sync::Arc;
use llm_worker::WorkerError;
use llm_worker::llm_client::client::LlmClient;
use llm_worker::llm_client::types::{Item, Role};
use session_store::Store;
use tokio::sync::{broadcast, mpsc, oneshot};
@ -19,6 +20,16 @@ use crate::spawn::registry::SpawnedPodRegistry;
use crate::spawn::tool::spawn_pod_tool;
use protocol::{AlertLevel, AlertSource, ErrorCode, Event, Method, RunResult, TurnResult};
fn is_system_message_item(item: &Item) -> bool {
matches!(
item,
Item::Message {
role: Role::System,
..
}
)
}
// ---------------------------------------------------------------------------
// PodHandle — client-facing, Clone-able
// ---------------------------------------------------------------------------
@ -246,6 +257,14 @@ impl PodController {
alerter_for_worker.alert(AlertLevel::Warn, AlertSource::Worker, message.to_owned());
});
let tx = event_tx.clone();
worker.on_history_append(move |item| {
if is_system_message_item(item) {
let value = serde_json::to_value(item).expect("Item is Serialize");
let _ = tx.send(Event::SystemMessage { item: value });
}
});
// Register the builtin file-manipulation tools (Read / Write /
// Edit / Glob / Grep / Bash). `ScopedFs` carries the pod-
// lifetime scope/pwd; `Tracker` is session-scoped — a fresh

View File

@ -6,7 +6,7 @@ use llm_worker::Item;
use llm_worker::llm_client::RequestConfig;
use llm_worker::llm_client::client::LlmClient;
use llm_worker::state::Mutable;
use llm_worker::{ToolOutputLimits, UsageRecord, Worker, WorkerError, WorkerResult};
use llm_worker::{Role, ToolOutputLimits, UsageRecord, Worker, WorkerError, WorkerResult};
use session_store::{EntryHash, PodScopeSnapshot, SessionId, SessionStartState, Store, StoreError};
use tracing::{info, warn};
@ -557,6 +557,20 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
}
}
fn broadcast_system_message_item(&self, item: &Item) {
if !matches!(
item,
Item::Message {
role: Role::System,
..
}
) {
return;
}
let value = serde_json::to_value(item).expect("Item is Serialize");
self.send_event(Event::SystemMessage { item: value });
}
/// Push a `Method::Notify` (or rendered `Method::PodEvent`) entry
/// onto the pending buffer.
///
@ -1466,19 +1480,29 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
+ reference_message.is_some() as usize
+ retained_items.len(),
);
new_history.push(Item::system_message(format!(
"[Compacted context summary]\n\n{summary_text}"
)));
let mut compact_introduced_system_messages =
Vec::with_capacity(2 + auto_read_messages.len() + reference_message.is_some() as usize);
let summary_message =
Item::system_message(format!("[Compacted context summary]\n\n{summary_text}"));
compact_introduced_system_messages.push(summary_message.clone());
compact_introduced_system_messages.extend(auto_read_messages.iter().cloned());
if let Some(msg) = reference_message.as_ref() {
compact_introduced_system_messages.push(msg.clone());
}
let task_snapshot_message = Item::system_message(format!(
"[Session TaskStore snapshot]\n\n{task_snapshot_text}\n\n\
This is the complete session task list preserved across compaction. \
The following TaskList tool result presents the same state through the tool lane."
));
compact_introduced_system_messages.push(task_snapshot_message.clone());
new_history.push(summary_message);
new_history.extend(auto_read_messages);
if let Some(msg) = reference_message {
new_history.push(msg);
}
new_history.extend(retained_items);
new_history.push(Item::system_message(format!(
"[Session TaskStore snapshot]\n\n{task_snapshot_text}\n\n\
This is the complete session task list preserved across compaction. \
The following TaskList tool result presents the same state through the tool lane."
)));
new_history.push(task_snapshot_message);
new_history.push(Item::tool_call("compact-tasklist", "TaskList", "{}"));
new_history.push(Item::tool_result_with_content(
"compact-tasklist",
@ -1531,8 +1555,11 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
self.user_segments.drain(..drop_n);
}
self.worker.as_mut().unwrap().set_history(new_history);
for item in &compact_introduced_system_messages {
self.broadcast_system_message_item(item);
}
let worker = self.worker.as_mut().unwrap();
worker.set_history(new_history);
// Anchor the prompt cache at the summary item so that Anthropic
// can place a durable `cache_control` breakpoint there — our
// compact layout guarantees history[0] is the summary.

View File

@ -14,6 +14,7 @@ use async_trait::async_trait;
use futures::Stream;
use llm_worker::Worker;
use llm_worker::llm_client::event::{Event as LlmEvent, ResponseStatus, StatusEvent};
use llm_worker::llm_client::types::Item;
use llm_worker::llm_client::{ClientError, LlmClient, Request};
use protocol::Event;
use session_store::FsStore;
@ -176,6 +177,56 @@ fn drain(rx: &mut broadcast::Receiver<Event>) -> Vec<Event> {
out
}
fn system_event_text(event: &Event) -> Option<&str> {
match event {
Event::SystemMessage { item } => item["content"]
.as_array()
.and_then(|parts| parts.iter().filter_map(|p| p["text"].as_str()).next()),
_ => None,
}
}
#[tokio::test]
async fn compact_broadcasts_only_new_system_messages_not_retained_ones() {
let client = MockClient::new(vec![
single_text_events("hi"),
write_summary_tool_use_events("call-1", "summary"),
single_text_events("done"),
]);
let mut pod = make_pod(client).await;
let (tx, mut rx) = broadcast::channel::<Event>(64);
pod.attach_event_tx(tx);
pod.run_text("first").await.unwrap();
let retained_message = Item::system_message("[Retained system]\nold");
pod.worker_mut().push_item(retained_message);
let _ = drain(&mut rx);
pod.compact(10_000).await.unwrap();
let events = drain(&mut rx);
let system_texts: Vec<&str> = events.iter().filter_map(system_event_text).collect();
assert!(
system_texts
.iter()
.any(|text| text.starts_with("[Compacted context summary]")),
"summary system message missing from {system_texts:?}"
);
assert!(
system_texts
.iter()
.any(|text| text.starts_with("[Session TaskStore snapshot]")),
"task snapshot system message missing from {system_texts:?}"
);
assert!(
!system_texts
.iter()
.any(|text| text.starts_with("[Retained system]")),
"retained system message should not be rebroadcast: {system_texts:?}"
);
}
#[tokio::test]
async fn post_run_compact_success_broadcasts_start_and_done() {
// Responses: (1) first run returns short text, (2) compact worker

View File

@ -223,6 +223,15 @@ pub enum Event {
Notify {
message: String,
},
/// Persisted `role:system` history item that should be rendered by
/// clients through the same path used for `Event::History` replay.
///
/// The payload is the serialized history item, not an ad-hoc display
/// DTO, so live subscribers and late subscribers have the same source
/// of truth: worker history / history.json.
SystemMessage {
item: serde_json::Value,
},
/// Echo of `Method::PodEvent` received by this Pod. Same rationale
/// as `Notify`: subscribers render the event as a log element,
/// while a rendered summary is independently injected into the LLM

View File

@ -304,6 +304,129 @@ impl App {
});
}
fn push_history_item(&mut self, item: &serde_json::Value) {
let item_type = item["type"].as_str().unwrap_or("");
match item_type {
"message" => {
let role = item["role"].as_str().unwrap_or("");
let text = message_text(item);
match role {
"user" => {
self.turn_index += 1;
self.blocks.push(Block::TurnHeader {
turn: self.turn_index,
});
// Pod attaches the original `Vec<Segment>` to user
// messages from live submissions, so we can rebuild
// typed atoms (paste chips, refs) here. Seed history
// loaded post-compaction has no `segments` field —
// fall back to a single text segment.
let segments = item
.get("segments")
.and_then(|v| serde_json::from_value::<Vec<Segment>>(v.clone()).ok())
.unwrap_or_else(|| {
if text.is_empty() {
Vec::new()
} else {
vec![Segment::text(text.clone())]
}
});
if !segments.is_empty() {
self.blocks.push(Block::UserMessage { segments });
}
}
"assistant" if !text.is_empty() => {
self.blocks.push(Block::AssistantText { text });
}
"system" if !text.is_empty() => {
self.blocks.push(Block::SystemMessage { text });
}
_ => {}
}
}
"tool_call" => {
// `Item::ToolCall` serializes the linking key as
// `call_id`; `id` is a separate optional item-level
// identifier. Use `call_id` so this matches how
// Event::ToolCallStart populates the block.
let id = item["call_id"].as_str().unwrap_or("").to_owned();
let name = item["name"].as_str().unwrap_or("?").to_owned();
let arguments = item["arguments"].as_str().map(|s| s.to_owned());
self.blocks.push(Block::ToolCall(ToolCallBlock {
id,
name,
args_stream: arguments.clone().unwrap_or_default(),
arguments,
state: ToolCallState::Executing,
edit_snapshot: None,
}));
}
"reasoning" => {
let text = item["text"].as_str().unwrap_or("").to_owned();
let body = if text.is_empty() {
item["summary"]
.as_array()
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str())
.collect::<Vec<_>>()
.join("\n")
})
.unwrap_or_default()
} else {
text
};
self.blocks.push(Block::Thinking(ThinkingBlock {
text: body,
state: ThinkingState::Finished { elapsed_secs: None },
}));
}
"tool_result" => {
let id = item["call_id"].as_str().unwrap_or("").to_owned();
let summary = item["summary"].as_str().unwrap_or("").to_owned();
let output = item["content"].as_str().map(|s| s.to_owned());
let is_error = item["is_error"].as_bool().unwrap_or(false);
let (name, args) = self
.find_tool_call_mut(&id)
.map(|b| (b.name.clone(), b.arguments.clone()))
.unwrap_or_default();
let edit_snapshot = if !is_error && name == "Edit" {
args.as_deref()
.and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok())
.and_then(|v| v["file_path"].as_str().map(|s| s.to_owned()))
.and_then(|path| self.cache.get(&path).map(|s| s.to_owned()))
} else {
None
};
if let Some(tc) = self.find_tool_call_mut(&id) {
if edit_snapshot.is_some() {
tc.edit_snapshot = edit_snapshot;
}
tc.state = if is_error {
ToolCallState::Error {
summary,
output: output.clone(),
}
} else {
ToolCallState::Done {
summary,
output: output.clone(),
}
};
if !is_error {
apply_cache_update(
&mut self.cache,
&name,
args.as_deref(),
output.as_deref(),
);
}
}
}
_ => {}
}
}
pub fn handle_pod_event(&mut self, event: Event) {
match event {
Event::UserMessage { segments } => {
@ -322,6 +445,10 @@ impl App {
self.blocks.push(Block::PodEvent { event });
self.assistant_streaming = false;
}
Event::SystemMessage { item } => {
self.push_history_item(&item);
self.assistant_streaming = false;
}
Event::TurnStart { .. } => {
self.running = true;
self.paused = false;
@ -670,130 +797,7 @@ impl App {
self.assistant_streaming = false;
for item in items {
let item_type = item["type"].as_str().unwrap_or("");
match item_type {
"message" => {
let role = item["role"].as_str().unwrap_or("");
let text = item["content"]
.as_array()
.and_then(|parts| parts.iter().filter_map(|p| p["text"].as_str()).next())
.unwrap_or("")
.to_owned();
match role {
"user" => {
self.turn_index += 1;
self.blocks.push(Block::TurnHeader {
turn: self.turn_index,
});
// Pod attaches the original `Vec<Segment>` to
// user messages from live submissions, so we
// can rebuild typed atoms (paste chips, refs)
// here. Seed history loaded post-compaction
// has no `segments` field — fall back to a
// single text segment.
let segments = item
.get("segments")
.and_then(|v| {
serde_json::from_value::<Vec<Segment>>(v.clone()).ok()
})
.unwrap_or_else(|| {
if text.is_empty() {
Vec::new()
} else {
vec![Segment::text(text.clone())]
}
});
if !segments.is_empty() {
self.blocks.push(Block::UserMessage { segments });
}
}
"assistant" if !text.is_empty() => {
self.blocks.push(Block::AssistantText { text });
}
_ => {}
}
}
"tool_call" => {
// `Item::ToolCall` serializes the linking key as
// `call_id`; `id` is a separate optional item-level
// identifier. Use `call_id` so this matches how
// Event::ToolCallStart populates the block.
let id = item["call_id"].as_str().unwrap_or("").to_owned();
let name = item["name"].as_str().unwrap_or("?").to_owned();
let arguments = item["arguments"].as_str().map(|s| s.to_owned());
self.blocks.push(Block::ToolCall(ToolCallBlock {
id,
name,
args_stream: arguments.clone().unwrap_or_default(),
arguments,
state: ToolCallState::Executing,
edit_snapshot: None,
}));
}
"reasoning" => {
let text = item["text"].as_str().unwrap_or("").to_owned();
let body = if text.is_empty() {
item["summary"]
.as_array()
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str())
.collect::<Vec<_>>()
.join("\n")
})
.unwrap_or_default()
} else {
text
};
self.blocks.push(Block::Thinking(ThinkingBlock {
text: body,
state: ThinkingState::Finished { elapsed_secs: None },
}));
}
"tool_result" => {
let id = item["call_id"].as_str().unwrap_or("").to_owned();
let summary = item["summary"].as_str().unwrap_or("").to_owned();
let output = item["content"].as_str().map(|s| s.to_owned());
let is_error = item["is_error"].as_bool().unwrap_or(false);
let (name, args) = self
.find_tool_call_mut(&id)
.map(|b| (b.name.clone(), b.arguments.clone()))
.unwrap_or_default();
let edit_snapshot = if !is_error && name == "Edit" {
args.as_deref()
.and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok())
.and_then(|v| v["file_path"].as_str().map(|s| s.to_owned()))
.and_then(|path| self.cache.get(&path).map(|s| s.to_owned()))
} else {
None
};
if let Some(tc) = self.find_tool_call_mut(&id) {
if edit_snapshot.is_some() {
tc.edit_snapshot = edit_snapshot;
}
tc.state = if is_error {
ToolCallState::Error {
summary,
output: output.clone(),
}
} else {
ToolCallState::Done {
summary,
output: output.clone(),
}
};
if !is_error {
apply_cache_update(
&mut self.cache,
&name,
args.as_deref(),
output.as_deref(),
);
}
}
}
_ => {}
}
self.push_history_item(item);
}
// Any tool_call entries that never got paired with a
@ -823,6 +827,19 @@ pub fn fmt_tokens(n: u64) -> String {
}
}
fn message_text(item: &serde_json::Value) -> String {
item["content"]
.as_array()
.map(|parts| {
parts
.iter()
.filter_map(|p| p["text"].as_str())
.collect::<Vec<_>>()
.join("\n")
})
.unwrap_or_default()
}
/// Strip the `cat -n` line-number gutter that the Read tool prepends to
/// its output (one `"{n:>6}\t{content}"` per line) and return the raw
/// file body. Lines that don't match the pattern are kept verbatim, so
@ -1185,6 +1202,58 @@ mod completion_flow_tests {
});
assert!(app.completion.as_ref().unwrap().entries.is_empty());
}
#[test]
fn history_restore_renders_system_message_block() {
let mut app = App::new("test".into());
app.handle_pod_event(Event::History {
greeting: test_greeting(),
items: vec![serde_json::json!({
"type": "message",
"role": "system",
"content": [{
"type": "text",
"text": "[File: src/main.rs]\nfn main() {}",
}],
})],
});
assert!(matches!(
app.blocks.get(1),
Some(Block::SystemMessage { text }) if text == "[File: src/main.rs]\nfn main() {}"
));
}
#[test]
fn live_system_message_event_uses_history_item_path() {
let mut app = App::new("test".into());
app.handle_pod_event(Event::SystemMessage {
item: serde_json::json!({
"type": "message",
"role": "system",
"content": [{
"type": "text",
"text": "[Workflow /build]\nRun the build",
}],
}),
});
assert!(matches!(
app.blocks.as_slice(),
[Block::SystemMessage { text }] if text == "[Workflow /build]\nRun the build"
));
}
fn test_greeting() -> protocol::Greeting {
protocol::Greeting {
pod_name: "test".into(),
cwd: "/tmp".into(),
provider: "test-provider".into(),
model: "test-model".into(),
scope_summary: String::new(),
tools: Vec::new(),
}
}
}
/// Seed / mutate the file-content cache based on a completed tool call.

View File

@ -19,6 +19,12 @@ pub enum Block {
UserMessage {
segments: Vec<Segment>,
},
/// Persisted `role:system` history item rendered as an ordinary log
/// element. File refs, auto-read snippets, workflow bodies, and future
/// system-message injections all share this lane.
SystemMessage {
text: String,
},
/// Echo of `Method::Notify` received by this Pod, surfaced as a log
/// element so subscribers see the external input that drove any
/// following auto-kicked turn.

View File

@ -361,6 +361,7 @@ fn render_block_into(lines: &mut Vec<Line<'static>>, block: &Block, width: u16,
)));
}
Block::UserMessage { segments } => render_user_message(lines, segments, width, mode),
Block::SystemMessage { text } => render_system_message(lines, text, width, mode),
Block::Notify { message } => {
let text = format!("[notify] {message}");
match mode {
@ -481,6 +482,77 @@ fn render_user_message(
}
}
fn render_system_message(lines: &mut Vec<Line<'static>>, text: &str, width: u16, mode: Mode) {
let header_style = kind_style(MessageKind::System);
let body_style = Style::default().fg(Color::DarkGray);
let (header, body) = split_system_message(text);
let overview_text = if body.is_empty() {
header.to_owned()
} else {
format!("{header} {body}")
};
match mode {
Mode::Overview => push_overview_line(lines, &overview_text, width, MessageKind::System, ""),
Mode::Detail => {
lines.push(Line::from(Span::styled(header.to_owned(), header_style)));
for raw in body.lines() {
lines.push(Line::from(vec![
Span::styled(" ", body_style),
Span::styled(raw.to_owned(), body_style),
]));
}
if body.is_empty() && header.is_empty() {
lines.push(Line::from(""));
}
}
Mode::Normal => {
lines.push(Line::from(Span::styled(header.to_owned(), header_style)));
let preview = system_message_preview(body, 4);
for line in preview.lines {
lines.push(Line::from(vec![
Span::styled(" ", body_style),
Span::styled(line, body_style),
]));
}
if preview.omitted_lines > 0 {
lines.push(Line::from(vec![
Span::styled(" ", body_style),
Span::styled(
format!("… ({} more lines)", preview.omitted_lines),
body_style.add_modifier(Modifier::ITALIC),
),
]));
}
}
}
}
fn split_system_message(text: &str) -> (&str, &str) {
match text.split_once('\n') {
Some((header, body)) => (header, body.trim_start_matches('\n')),
None => (text, ""),
}
}
struct SystemMessagePreview {
lines: Vec<String>,
omitted_lines: usize,
}
fn system_message_preview(body: &str, max_lines: usize) -> SystemMessagePreview {
let all: Vec<&str> = body.lines().collect();
let lines = all
.iter()
.take(max_lines)
.map(|line| (*line).to_owned())
.collect();
SystemMessagePreview {
lines,
omitted_lines: all.len().saturating_sub(max_lines),
}
}
/// Style + display text for a single chip-style `Segment`. `fallback`
/// is used for `Segment::Text` (which the caller handles inline) and
/// for `Segment::Unknown` so future variants degrade gracefully.
@ -931,6 +1003,8 @@ pub enum MessageKind {
/// Visually distinct from User / Assistant / Notice so it's clear
/// the line came from another Pod or operator, not the local user.
Notify,
/// Persisted role:system history item preview.
System,
Assistant,
Thinking,
TurnStats,
@ -943,6 +1017,7 @@ pub fn kind_style(kind: MessageKind) -> Style {
MessageKind::TurnHeader => Style::default().fg(Color::DarkGray),
MessageKind::User => Style::default().fg(Color::Green),
MessageKind::Notify => Style::default().fg(Color::Yellow),
MessageKind::System => Style::default().fg(Color::Cyan),
MessageKind::Assistant => Style::default().fg(Color::White),
MessageKind::Thinking => Style::default()
.fg(Color::Magenta)
@ -975,7 +1050,9 @@ fn format_pod_event(event: &PodEvent) -> String {
format!("[pod_event] {pod_name} → shut_down")
}
PodEvent::ScopeSubDelegated {
parent_pod, sub_pod, ..
parent_pod,
sub_pod,
..
} => {
format!("[pod_event] {parent_pod} → scope_sub_delegated: {sub_pod}")
}

View File

@ -1,47 +0,0 @@
# TUI で role:system の system message を表示する
## 背景
Pod は user 入力の `@<path>` chip / `/<slug>` chip を submit 時に展開し、`Item::system_message` を `pending_attachments` 経由で worker.history に commit している:
- `@<path>` ライブ submit: `Pod::run``resolve_file_refs` (crates/pod/src/pod.rs:825,852) → `PodFsView::resolve_file_ref` (crates/pod/src/fs_view.rs:119) で `[File: <path>]\n<body>` を生成
- compact worker の auto-read: `mark_read_required` で nominate された再読対象が `PodFsView::render_auto_read` (crates/pod/src/fs_view.rs:84) で `[Auto-read file: <path>:<range>]\n<body>` として乗る
- `/<slug>` ライブ submit: `Pod::run``resolve_workflow_invocations` (crates/pod/src/pod.rs:826,876) → `crate::workflow::resolve_workflow_invocation` (crates/pod/src/workflow/mod.rs:74) で `[Workflow /<slug>]\n<body>` と requires Knowledge 毎の `[Workflow /<slug> requires Knowledge #<req>]\n<body>` を生成
いずれも `PodInterceptor::on_prompt_submit` (crates/pod/src/ipc/interceptor.rs:114) で `PromptAction::ContinueWith(extras)` 経由で worker.history に commit され、history.json に永続化されている (CLAUDE.md「許される加工」原則に整合)。
## 問題
LLM のコンテキストには乗っているが TUI には何も出ない。TUI 側で role:system の Item が表示経路に乗っていないため:
1. **ライブ側**: 解決済み system message を運ぶ broadcast event が `protocol::Event` に存在しない (crates/protocol/src/lib.rs:204326)。`UserMessage` で `@<path>` / `/<slug>` chip 自体は表示されるが、解決された本体は出ない。失敗時のみ `Alert` で出るが、`Alert` はユーザー向け一過性通知で永続化されない (crates/protocol/src/lib.rs:328348) ため表示経路として不適切。
2. **履歴復元側**: `App::restore_history` (crates/tui/src/app.rs:650702) の match が `role: "user"` / `"assistant"` 以外を `_ => {}` で握り潰す。history.json に system role で残っているのに resume 時に消える。
結果として「`@<path>` や `/<slug>` を submit したのに、本当に読まれたのか / 何が context に乗ったのか TUI からは判別できない」状態になっている。
## 要件
- `Item::system_message` (role:system) を user / assistant メッセージと並列のログ要素として TUI に表示する**一般的な仕組み**を入れる。種別ごとの個別パッチではなく、role:system が来たら一律で表示経路に乗せる形にする。
- 仕組みとして最小限カバーすべき system message:
- `[File: <path>]` (ライブ `@<path>`)
- `[Auto-read file: <path>:<range>]` (compact 後の auto-read)
- `[Workflow /<slug>]`
- `[Workflow /<slug> requires Knowledge #<req>]`
- 表示の単一の情報源は永続化された history (= history.json の role:system Item)。ライブ submit 時 / 履歴復元時 / 別 client subscribe 時 の三経路で同じ Block バリアントを通る。
- 表示は本文プレビュー(数行 + 残行数 / 切り詰め注記)程度で良い。`[Auto-read file: ...:<range>]` の range ラベルは可視化する。workflow 本体と requires Knowledge は同じ workflow 起動に紐づく一連と分かる粒度で識別できる。
- 解決失敗時の従来経路は維持: `@<path>``Alert`、workflow は user-invocation エラーとして即座に Pod から返る (`Pod::validate_workflow_invocations`)。
## 範囲外 / 非目標
- `<system-reminder>` 注入機構そのものの汎用化や、notify_wrapper 適用後の本文表示。これらは別所(`session-todo-reminder` 等)。本チケットは**表示側の仕組みのみ**で、将来 `<system-reminder>` 系が role:system Item として history に乗るようになれば、同じ表示経路にそのまま流れる前提。
- 表示形式の凝った装飾(シンタックスハイライト / 折り畳み UI。最初は素のテキストプレビューで十分。
- `model_invokation: true` のみの workflowuser_invocable=falseの表示は対象外。
## 完了条件
- `@<path>` を submit すると、user message ブロックに続けて自動読み取り結果が TUI に出る。本文プレビューが視認できる。
- `/<slug>` を submit すると、workflow 起動結果のログ要素が TUI に出る。`requires` がある場合は Knowledge 注入もそれと分かる形で出る。
- compact 後の resume で `[Auto-read file: ...]` が同じログ要素として復元・表示される。
- 別 client が後から subscribe して `Event::History` を受けた場合も、同じログ要素として描画される。
- ライブ event と history 復元の表示が一致する(同じ Block バリアントを通る)。
- 解決失敗時の従来経路(`Alert` / user-invocation エラー)は維持される。