Compare commits

..

10 Commits

22 changed files with 847 additions and 182 deletions

View File

@ -693,21 +693,15 @@ async fn controller_loop<C, St>(
});
continue;
}
// Broadcast the user message so every subscriber
// (including the submitter) can render the turn header
// + user line from a single source of truth.
// shared_state's `user_segments` is re-synced from
// `pod` after the run completes, so we don't push
// here. Workflow-invocation validation happens inside
// `Pod::run`; on failure the turn errors out via
// `Event::Error { InvalidRequest }` before any
// UserInput is committed. Paused→Run cleanup (orphan
// tool_result closure + interrupt system note) is
// applied inside `Pod::run` itself when the worker's
// `last_run_interrupted` flag is set.
let _ = event_tx.send(Event::UserMessage {
segments: input.clone(),
});
// Stage the run without a speculative user-message echo.
// `Pod::run` validates the input, commits
// `LogEntry::UserInput`, and the session-log sink turns that
// committed entry into the live `Event::UserMessage`. That
// keeps every client ordered against `SegmentStart` replay and
// makes persisted history the single source of visible user
// input. Paused→Run cleanup (orphan tool_result closure +
// interrupt system note) is applied inside `Pod::run` itself
// when the worker's `last_run_interrupted` flag is set.
pending = Some(PendingRun::Run(input));
}

View File

@ -68,6 +68,37 @@ fn is_peer_disconnect_read_error(error: &io::Error) -> bool {
)
}
fn live_entry_event(entry: session_store::LogEntry) -> Option<Event> {
match entry {
session_store::LogEntry::SegmentStart { .. } => {
let value = serde_json::to_value(&entry).expect("LogEntry is Serialize");
Some(Event::SegmentRotated { entry: value })
}
session_store::LogEntry::UserInput { segments, .. } => {
Some(Event::UserMessage { segments })
}
session_store::LogEntry::SystemItem { item, .. } => {
let value = serde_json::to_value(&item).expect("SystemItem is Serialize");
Some(Event::SystemItem { item: value })
}
session_store::LogEntry::Invoke { trigger, .. } => {
Some(Event::InvokeStart { kind: trigger })
}
other => {
// `SegmentLogSink::is_live_relevant` keeps non-live-relevant
// variants off the broadcast lane; reaching here means the two
// are out of sync and we silently dropped a wire event. Log so a
// future regression surfaces instead of vanishing.
tracing::error!(
entry_kind = ?std::mem::discriminant(&other),
"session-log broadcast emitted a non-live-relevant entry; \
sink filter and IPC dispatch are out of sync"
);
None
}
}
}
async fn handle_connection(stream: tokio::net::UnixStream, handle: PodHandle) {
let (reader, writer) = stream.into_split();
let mut reader = JsonLineReader::new(reader);
@ -108,43 +139,13 @@ async fn handle_connection(stream: tokio::net::UnixStream, handle: PodHandle) {
loop {
tokio::select! {
// Live session-log entries → dispatched as the role-specific
// wire events. The sink only broadcasts entries that the
// streaming-event lane doesn't cover; everything else is
// already on the wire via TextDelta / ToolCall* / etc., so we
// never see (and never need to forward) other variants here.
// wire events. `SegmentLogSink` only broadcasts committed log
// entries with live UI meaning; `UserInput` travels this lane so
// the visible user line is ordered with `SegmentStart` rotation.
entry = entry_rx.recv() => {
match entry {
Ok(entry) => {
let outbound = match entry {
session_store::LogEntry::SegmentStart { .. } => {
let value = serde_json::to_value(&entry)
.expect("LogEntry is Serialize");
Some(Event::SegmentRotated { entry: value })
}
session_store::LogEntry::SystemItem { item, .. } => {
let value = serde_json::to_value(&item)
.expect("SystemItem is Serialize");
Some(Event::SystemItem { item: value })
}
session_store::LogEntry::Invoke { trigger, .. } => {
Some(Event::InvokeStart { kind: trigger })
}
other => {
// `SegmentLogSink::is_live_relevant` keeps
// non-live-relevant variants off the
// broadcast lane; reaching here means the
// two are out of sync and we silently
// dropped a wire event. Log so a future
// regression surfaces instead of vanishing.
tracing::error!(
entry_kind = ?std::mem::discriminant(&other),
"session-log broadcast emitted a non-live-relevant entry; \
sink filter and IPC dispatch are out of sync"
);
None
}
};
if let Some(event) = outbound {
if let Some(event) = live_entry_event(entry) {
if writer.write(&event).await.is_err() {
break;
}
@ -261,4 +262,19 @@ mod tests {
let error = io::Error::new(ErrorKind::InvalidData, "malformed method");
assert!(!is_peer_disconnect_read_error(&error));
}
#[test]
fn user_input_log_entry_maps_to_user_message_event() {
let segments = vec![protocol::Segment::text("hello from log")];
let event = live_entry_event(session_store::LogEntry::UserInput {
ts: session_store::segment_log::now_millis(),
segments: segments.clone(),
})
.expect("UserInput must be live-relevant");
match event {
Event::UserMessage { segments: echoed } => assert_eq!(echoed, segments),
other => panic!("expected UserMessage, got {other:?}"),
}
}
}

View File

@ -87,18 +87,19 @@ impl SegmentLogSink {
/// entry to the underlying `Store` — disk write is the gate. Failed
/// disk writes must not call `publish`.
///
/// Live broadcast fires only for entries that the streaming-event
/// lane does not cover:
/// Live broadcast fires for committed session-log entries that
/// socket clients must see in log order:
/// - `LogEntry::SegmentStart` → `Event::SegmentRotated` on the wire.
/// - `LogEntry::UserInput` → `Event::UserMessage`.
/// - `LogEntry::SystemItem` → `Event::SystemItem`.
/// - `LogEntry::Invoke` → `Event::InvokeStart`.
/// Everything else (AssistantItem, ToolResult, UserInput, TurnEnd,
/// Everything else (AssistantItem, ToolResult, TurnEnd,
/// RunCompleted, RunErrored, LlmUsage, Extension, ConfigChanged) is
/// reflected in the mirror so reconnect snapshots stay accurate,
/// but is not sent live — the streaming events (TextDelta /
/// ToolCallStart / ToolResult / UserMessage / TurnEnd / etc.)
/// already provide that data, and re-broadcasting it as a typed
/// entry would just double-render every block on the client side.
/// ToolCallStart / ToolResult / TurnEnd / etc.) already provide
/// that data, and re-broadcasting it as a typed entry would just
/// double-render every block on the client side.
pub fn publish(&self, entry: LogEntry) {
let mut mirror = self
.inner
@ -119,7 +120,10 @@ impl SegmentLogSink {
fn is_live_relevant(entry: &LogEntry) -> bool {
matches!(
entry,
LogEntry::SegmentStart { .. } | LogEntry::SystemItem { .. } | LogEntry::Invoke { .. }
LogEntry::SegmentStart { .. }
| LogEntry::UserInput { .. }
| LogEntry::SystemItem { .. }
| LogEntry::Invoke { .. }
)
}
@ -227,6 +231,15 @@ mod tests {
}
}
fn user_input(text: &str) -> LogEntry {
LogEntry::UserInput {
ts: now_millis(),
segments: vec![protocol::Segment::Text {
content: text.to_owned(),
}],
}
}
#[test]
fn publish_then_subscribe_returns_history_in_snapshot() {
let sink = SegmentLogSink::new();
@ -265,6 +278,16 @@ mod tests {
sink.publish(turn_end(1));
assert!(rx.try_recv().is_err(), "TurnEnd must not be broadcast live");
// UserInput is live-relevant because it is the persisted source
// for Event::UserMessage.
sink.publish(user_input("hi from log"));
match rx.try_recv() {
Ok(LogEntry::UserInput { segments, .. }) => {
assert_eq!(segments.len(), 1);
}
other => panic!("expected UserInput, got {other:?}"),
}
// SystemItem is live-relevant.
sink.publish(notification_entry("hi"));
match rx.try_recv() {
@ -272,9 +295,9 @@ mod tests {
other => panic!("expected SystemItem, got {other:?}"),
}
// Mirror still grew with both entries (snapshot completeness).
// Mirror still grew with all entries (snapshot completeness).
let (after_snapshot, _) = sink.subscribe_with_snapshot();
assert_eq!(after_snapshot.len(), 3);
assert_eq!(after_snapshot.len(), 4);
}
#[test]

View File

@ -571,13 +571,14 @@ async fn run_with_paste_segment_inlines_content_and_emits_typed_user_message() {
let client_for_assert = client.clone();
let pod = make_pod(client).await;
let handle = spawn_controller(pod).await;
let mut rx = handle.subscribe();
let (_snapshot, mut entry_rx) = handle.sink.subscribe_with_snapshot();
let mut event_rx = handle.subscribe();
// Mixed input: plain text + a paste chip + trailing text. Pod must
// flatten this into one user-message string (paste content inlined,
// no `[Clipboard ...]` label leaking to the LLM); the
// `Event::UserMessage` re-broadcast must carry the typed segments
// unchanged so other clients can re-render the chip.
// no `[Clipboard ...]` label leaking to the LLM); the committed
// `LogEntry::UserInput` must carry the typed segments unchanged so
// socket clients can derive `Event::UserMessage` and re-render the chip.
let segments = vec![
protocol::Segment::text("see "),
protocol::Segment::Paste {
@ -596,21 +597,36 @@ async fn run_with_paste_segment_inlines_content_and_emits_typed_user_message() {
.unwrap();
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(2);
let mut user_event_segments: Option<Vec<protocol::Segment>> = None;
let mut saw_turn_end = false;
let mut user_input_segments: Option<Vec<protocol::Segment>> = None;
loop {
tokio::select! {
event = rx.recv() => match event {
Ok(Event::UserMessage { segments }) => user_event_segments = Some(segments),
Ok(Event::TurnEnd { .. }) => break,
event = event_rx.recv() => match event {
Ok(Event::TurnEnd { .. }) => {
saw_turn_end = true;
if user_input_segments.is_some() {
break;
}
}
Err(_) => break,
_ => {}
},
entry = entry_rx.recv() => match entry {
Ok(session_store::LogEntry::UserInput { segments, .. }) => {
user_input_segments = Some(segments);
if saw_turn_end {
break;
}
}
Err(_) => break,
_ => {}
},
_ = tokio::time::sleep_until(deadline) => break,
}
}
let echoed = user_event_segments.expect("UserMessage event missing");
assert_eq!(echoed.len(), 3, "all three segments must round-trip");
assert!(matches!(echoed[1], protocol::Segment::Paste { id: 7, .. }));
assert!(saw_turn_end, "TurnEnd event missing");
let echoed = user_input_segments.expect("committed UserInput entry missing");
assert_eq!(echoed, segments, "typed segments must round-trip unchanged");
// The Worker received a single user message whose text is the
// flattened body — paste content inlined, no chip label.

View File

@ -243,14 +243,14 @@ impl Method {
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "event", content = "data", rename_all = "snake_case")]
pub enum Event {
/// A user input message was accepted by the Pod and is about to
/// start a new turn. Broadcast to every subscribed client so
/// additional TUI / GUI instances show the same pending user line
/// that the submitter already sees — without this event, non-
/// submitting clients would see tool calls and assistant text
/// appear without any preceding user message.
/// A user input message was accepted, persisted as
/// `LogEntry::UserInput`, and is about to start a new turn.
/// Broadcast to every subscribed client so TUI / GUI instances show
/// the same user line that reconnect snapshots would replay from
/// history; clients must not synthesize a separate pending/fake
/// message for accepted runs.
///
/// Fires exactly once per accepted `Method::Run`, after
/// Fires exactly once per committed user input, after
/// `InvokeStart { kind: UserSend }` and before the first
/// `TurnStart`. Rejected runs (e.g. `AlreadyRunning`) do not emit.
UserMessage {

View File

@ -568,11 +568,10 @@ impl App {
}
fn method_for_run(&mut self, segments: Vec<Segment>) -> Method {
// TurnHeader / UserMessage blocks are pushed in response to
// `Event::UserMessage` (single source of truth, shared by every
// client subscribed to the Pod). Locally we only clear the
// input buffer and forward the method, while remembering enough
// local state to undo the visible submit if the Pod reports that
// TurnHeader / UserMessage blocks are pushed only after the Pod
// emits `Event::UserMessage` from a committed `LogEntry::UserInput`.
// Locally we only clear the input buffer and forward the method,
// while remembering enough local state to undo the visible submit if
// the accepted run produced no assistant output and was rolled back.
self.pending_submit_rollback = Some(RollbackSubmitState {
text: Segment::flatten_to_text(&segments),
@ -2317,6 +2316,34 @@ mod completion_flow_tests {
assert!(app.completion.as_ref().unwrap().entries.is_empty());
}
#[test]
fn committed_user_message_survives_fresh_segment_rotation() {
let mut app = App::new("test".into());
let start = session_store::LogEntry::SegmentStart {
ts: session_store::segment_log::now_millis(),
session_id: uuid::Uuid::nil(),
system_prompt: None,
config: llm_worker::llm_client::RequestConfig::default(),
history: vec![],
forked_from: None,
compacted_from: None,
};
app.handle_pod_event(Event::SegmentRotated {
entry: serde_json::to_value(start).expect("LogEntry is Serialize"),
});
app.handle_pod_event(Event::UserMessage {
segments: vec![Segment::text("first persisted message")],
});
assert_eq!(app.turn_index, 1);
assert!(app.blocks.iter().any(|b| matches!(
b,
Block::UserMessage { segments }
if Segment::flatten_to_text(segments) == "first persisted message"
)));
}
#[test]
fn rolled_back_run_restores_input_and_removes_submit_blocks() {
let mut app = App::new("test".into());

View File

@ -1,18 +1,15 @@
use std::io::{self, Stdout};
use std::io::{self, Stdout, Write};
use std::process::ExitCode;
use std::time::Duration;
use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
use crossterm::execute;
use crossterm::terminal::{
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
};
use ratatui::Terminal;
use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Constraint, Direction, Layout};
use ratatui::style::{Modifier, Style};
use ratatui::layout::{Constraint, Layout};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap};
use ratatui::widgets::Paragraph;
use ratatui::{Frame, Terminal, TerminalOptions, Viewport};
use secrets::{SecretStore, SecretValue};
#[derive(Debug, Clone, PartialEq, Eq)]
@ -238,12 +235,16 @@ pub async fn launch() -> ExitCode {
}
type UiResult<T> = Result<T, Box<dyn std::error::Error>>;
type InlineTerminal = Terminal<CrosstermBackend<Stdout>>;
struct TerminalRestoreGuard {
const MAX_ROWS: usize = 10;
const VIEWPORT_LINES: u16 = MAX_ROWS as u16 + 5;
struct RawModeGuard {
active: bool,
}
impl TerminalRestoreGuard {
impl RawModeGuard {
fn new() -> Self {
Self { active: true }
}
@ -254,12 +255,11 @@ impl TerminalRestoreGuard {
}
fn cleanup(&mut self) {
let _ = execute!(io::stdout(), crossterm::cursor::Show, LeaveAlternateScreen);
let _ = disable_raw_mode();
}
}
impl Drop for TerminalRestoreGuard {
impl Drop for RawModeGuard {
fn drop(&mut self) {
if self.active {
self.cleanup();
@ -269,18 +269,41 @@ impl Drop for TerminalRestoreGuard {
fn run(store: SecretStore) -> UiResult<()> {
enable_raw_mode()?;
let guard = TerminalRestoreGuard::new();
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, crossterm::cursor::Hide)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let guard = RawModeGuard::new();
let mut terminal = make_inline_terminal()?;
let result = run_loop(&mut terminal, store);
let close_result = close_viewport(&mut terminal);
drop(terminal);
guard.restore();
result
result?;
close_result?;
Ok(())
}
fn run_loop(terminal: &mut Terminal<CrosstermBackend<Stdout>>, store: SecretStore) -> UiResult<()> {
fn make_inline_terminal() -> io::Result<InlineTerminal> {
let backend = CrosstermBackend::new(io::stdout());
Terminal::with_options(
backend,
TerminalOptions {
viewport: Viewport::Inline(VIEWPORT_LINES),
},
)
}
/// Park the cursor at the very bottom of the inline viewport and emit one
/// newline before dropping the terminal. This matches the resume picker and
/// keeps the shell prompt (or a later inline viewport) from drawing over rows.
fn close_viewport(terminal: &mut InlineTerminal) -> io::Result<()> {
let area = terminal.get_frame().area();
let last_row = area.bottom().saturating_sub(1);
terminal.set_cursor_position((0, last_row))?;
let mut out = io::stdout();
out.write_all(b"\r\n")?;
out.flush()?;
Ok(())
}
fn run_loop(terminal: &mut InlineTerminal, store: SecretStore) -> UiResult<()> {
let mut app = KeysApp::new(load_ids(&store)?);
loop {
terminal.draw(|frame| draw(frame, &app))?;
@ -327,59 +350,188 @@ fn load_ids(store: &SecretStore) -> UiResult<Vec<String>> {
.collect())
}
fn draw(frame: &mut ratatui::Frame<'_>, app: &KeysApp) {
fn draw(frame: &mut Frame<'_>, app: &KeysApp) {
let area = frame.area();
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(4),
Constraint::Length(5),
])
.split(area);
let title = Paragraph::new("Local secret keys (values are never displayed)").block(
Block::default()
.borders(Borders::ALL)
.title("insomnia keys"),
);
frame.render_widget(title, chunks[0]);
let items: Vec<ListItem<'_>> = if app.ids.is_empty() {
vec![ListItem::new(Line::from(Span::raw("No keys stored")))]
} else {
app.ids
.iter()
.map(|id| ListItem::new(Line::from(Span::raw(id.clone()))))
.collect()
};
let list = List::new(items)
.block(Block::default().borders(Borders::ALL).title("Key ids"))
.highlight_style(Style::default().add_modifier(Modifier::REVERSED));
let mut state = ListState::default();
if !app.ids.is_empty() {
state.select(Some(app.selected));
let mut constraints = Vec::with_capacity(MAX_ROWS + 5);
constraints.push(Constraint::Length(1)); // title
for _ in 0..MAX_ROWS {
constraints.push(Constraint::Length(1)); // key rows
}
frame.render_stateful_widget(list, chunks[1], &mut state);
constraints.push(Constraint::Length(1)); // mode / actions
constraints.push(Constraint::Length(1)); // notice
constraints.push(Constraint::Length(1)); // protection note
constraints.push(Constraint::Length(1)); // spacer
let layout = Layout::vertical(constraints).split(area);
let input_line = match &app.mode {
Mode::Normal => "a add/set d delete ↑/↓ select q quit".to_string(),
Mode::EditingId => format!("Secret id: {}", app.input),
Mode::EditingValue { id } => format!(
"Value for `{id}`: {}",
"".repeat(app.input.chars().count())
frame.render_widget(Paragraph::new(title_line(app)), layout[0]);
let (start, end) = visible_key_window(app.ids.len(), app.selected);
for row in 0..MAX_ROWS {
let line = if app.ids.is_empty() && row == 0 {
empty_keys_line()
} else if let Some(id) = app.ids.get(start + row).filter(|_| start + row < end) {
key_row_line(id, start + row == app.selected)
} else {
Line::from("")
};
frame.render_widget(Paragraph::new(line), layout[row + 1]);
}
let mode_row = MAX_ROWS + 1;
frame.render_widget(Paragraph::new(mode_line(app)), layout[mode_row]);
frame.render_widget(Paragraph::new(notice_line(app)), layout[MAX_ROWS + 2]);
frame.render_widget(Paragraph::new(protection_line()), layout[MAX_ROWS + 3]);
if let Some(cursor_col) = mode_cursor_col(app) {
let col = cursor_col.min(area.width.saturating_sub(1) as usize);
frame.set_cursor_position((layout[mode_row].x + col as u16, layout[mode_row].y));
}
}
fn title_line(app: &KeysApp) -> Line<'_> {
Line::from(vec![
Span::styled(
"insomnia keys local secrets",
Style::default().add_modifier(Modifier::BOLD),
),
Mode::ConfirmDelete { id } => format!("Delete `{id}`? y/N"),
};
let help = Paragraph::new(vec![
Line::from(input_line),
Line::from(app.notice.clone()),
Line::from("Protection is local obfuscation at rest, not a system keychain."),
Span::raw(" "),
Span::styled(
key_count_label(app.ids.len()),
Style::default().fg(Color::DarkGray),
),
Span::raw(" "),
Span::styled("values hidden", Style::default().fg(Color::DarkGray)),
])
.block(Block::default().borders(Borders::ALL).title("Actions"))
.wrap(Wrap { trim: true });
frame.render_widget(Clear, chunks[2]);
frame.render_widget(help, chunks[2]);
}
fn key_count_label(count: usize) -> String {
match count {
0 => "0 ids".to_string(),
1 => "1 id".to_string(),
n => format!("{n} ids"),
}
}
fn visible_key_window(total: usize, selected: usize) -> (usize, usize) {
if total <= MAX_ROWS {
return (0, total);
}
let last_start = total - MAX_ROWS;
let start = selected.saturating_sub(MAX_ROWS / 2).min(last_start);
(start, start + MAX_ROWS)
}
fn empty_keys_line() -> Line<'static> {
Line::from(vec![
Span::raw(" "),
Span::styled("No keys stored", Style::default().fg(Color::DarkGray)),
])
}
fn key_row_line(id: &str, selected: bool) -> Line<'_> {
let marker = if selected { "" } else { " " };
let id_style = if selected {
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Cyan)
};
Line::from(vec![
Span::raw(marker),
Span::styled(id, id_style),
Span::raw(" "),
Span::styled("[secret id]", Style::default().fg(Color::DarkGray)),
])
}
fn mode_line(app: &KeysApp) -> Line<'_> {
match &app.mode {
Mode::Normal => Line::from(vec![
Span::raw(" "),
Span::styled("[a]", Style::default().fg(Color::Green)),
Span::raw(" add/set "),
Span::styled("[d]", Style::default().fg(Color::Yellow)),
Span::raw(" delete "),
Span::styled("[↑/↓]", Style::default().fg(Color::DarkGray)),
Span::raw(" select "),
Span::styled("[q]", Style::default().fg(Color::Yellow)),
Span::raw(" quit"),
]),
Mode::EditingId => Line::from(vec![
Span::raw(" "),
Span::styled("secret id: ", Style::default().fg(Color::DarkGray)),
Span::styled(
app.input.as_str(),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
]),
Mode::EditingValue { id } => Line::from(vec![
Span::raw(" "),
Span::styled("value for `", Style::default().fg(Color::DarkGray)),
Span::styled(id.as_str(), Style::default().fg(Color::Cyan)),
Span::styled("`: ", Style::default().fg(Color::DarkGray)),
Span::styled(
"".repeat(app.input.chars().count()),
Style::default().fg(Color::Green),
),
]),
Mode::ConfirmDelete { id } => Line::from(vec![
Span::raw(" "),
Span::styled("delete `", Style::default().fg(Color::DarkGray)),
Span::styled(id.as_str(), Style::default().fg(Color::Cyan)),
Span::styled("`? ", Style::default().fg(Color::DarkGray)),
Span::styled("[y]", Style::default().fg(Color::Red)),
Span::raw(" yes "),
Span::styled("[n/esc]", Style::default().fg(Color::Yellow)),
Span::raw(" cancel"),
]),
}
}
fn notice_line(app: &KeysApp) -> Line<'_> {
if app.notice.is_empty() {
return Line::from("");
}
Line::from(vec![
Span::raw(" "),
Span::styled(app.notice.as_str(), notice_style(app.notice.as_str())),
])
}
fn notice_style(notice: &str) -> Style {
if notice.contains("failed") || notice.contains("must not") {
Style::default().fg(Color::Red)
} else if notice.starts_with("Saved") || notice.starts_with("Deleted") {
Style::default().fg(Color::Green)
} else {
Style::default().fg(Color::DarkGray)
}
}
fn protection_line() -> Line<'static> {
Line::from(vec![
Span::raw(" "),
Span::styled(
"local obfuscation at rest; not a system keychain",
Style::default().fg(Color::DarkGray),
),
])
}
fn mode_cursor_col(app: &KeysApp) -> Option<usize> {
match &app.mode {
Mode::EditingId => Some(" secret id: ".chars().count() + app.input.chars().count()),
Mode::EditingValue { id } => Some(
" value for `".chars().count()
+ id.chars().count()
+ "`: ".chars().count()
+ app.input.chars().count(),
),
Mode::Normal | Mode::ConfirmDelete { .. } => None,
}
}
#[cfg(test)]
@ -431,4 +583,26 @@ mod tests {
}
);
}
fn line_text(line: Line<'_>) -> String {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect()
}
#[test]
fn renderer_masks_editing_value() {
let mut app = KeysApp::new(vec!["web/brave/test".into()]);
app.mode = Mode::EditingValue {
id: "web/brave/test".into(),
};
app.input = "secret-value".into();
let text = line_text(mode_line(&app));
assert!(text.contains("web/brave/test"));
assert!(text.contains("••••••••••••"));
assert!(!text.contains("secret-value"));
}
}

View File

@ -3,40 +3,40 @@ local scope = require("insomnia.scope")
local compact = require("insomnia.compact")
return profile {
slug = "default",
description = "Bundled default Insomnia coding profile",
slug = "default",
description = "Bundled default Insomnia coding profile",
scope = scope.workspace_write(),
scope = scope.workspace_write(),
session = {
record_event_trace = true,
},
worker = {
reasoning = "high",
},
model = {
ref = "codex-oauth/gpt-5.5",
},
compaction = compact.tokens {
threshold = 200000,
request_threshold = 240000,
worker_context_max_tokens = 100000,
},
memory = {
extract_threshold = 50000,
consolidation_threshold_files = 5,
consolidation_threshold_bytes = 50000,
},
web = {
enabled = true,
search = {
provider = "brave",
api_key_secret = "web/brave/default",
session = {
record_event_trace = true,
},
worker = {
reasoning = "high",
},
model = {
ref = "codex-oauth/gpt-5.5",
},
compaction = compact.tokens {
threshold = 240000,
request_threshold = 270000,
worker_context_max_tokens = 100000,
},
memory = {
extract_threshold = 50000,
consolidation_threshold_files = 5,
consolidation_threshold_bytes = 50000,
},
web = {
enabled = true,
search = {
provider = "brave",
api_key_secret = "web/brave/default",
},
},
},
}

View File

@ -0,0 +1,63 @@
## Investigation summary
Read-only investigation found that the most likely cause is live-event ordering between `Event::UserMessage` and the first `Event::SegmentRotated` in a fresh session.
Observed flow:
1. TUI submit path calls `App::submit_input()` / `method_for_run()` and sends `Method::Run { input }`.
2. `crates/pod/src/controller.rs` handles `Method::Run` and currently broadcasts `Event::UserMessage { segments }` before calling `pod.run(input)`.
3. TUI receives `Event::UserMessage` and appends a `TurnHeader` / `UserMessage` block.
4. `Pod::run()` calls `prepare_for_run()` / `ensure_segment_head()`.
5. In a fresh session, `entries_written == 0`, so `LogEntry::SegmentStart` is committed only at this point.
6. `crates/pod/src/segment_log_sink.rs` converts `SegmentStart` into `Event::SegmentRotated`.
7. TUI handles `SegmentRotated` by clearing blocks through `reset_for_rotation()` and replaying `SegmentStart.history`.
8. The current `UserInput` has not been committed yet, so `SegmentStart.history` does not contain the first user message.
9. Later `LogEntry::UserInput` is committed, but the live path does not emit `Event::UserMessage` from that commit, so the cleared user block is not restored in the live TUI view.
Likely affected files / functions:
- `crates/tui/src/app.rs`
- `App::submit_input()`
- `method_for_run()`
- `App::handle_pod_event(Event::UserMessage)`
- `App::handle_pod_event(Event::SegmentRotated)`
- `reset_for_rotation()`
- `crates/pod/src/controller.rs`
- `Method::Run` branch
- `crates/pod/src/pod.rs`
- `Pod::run()`
- `prepare_for_run()`
- `ensure_segment_head()`
- `crates/pod/src/segment_log_sink.rs`
- session-log-derived live event conversion
## Implementation intent
Move the live `Event::UserMessage` authority away from the controller's pre-`pod.run()` optimistic broadcast and toward the persisted `LogEntry::UserInput` commit path.
Preferred shape:
- Remove the controller-side `Event::UserMessage` broadcast that happens before `pod.run(input)`.
- Emit `Event::UserMessage` when `LogEntry::UserInput` is committed.
- Prefer doing this in the session-log-derived event lane, such as `SegmentLogSink`, so `SegmentStart`, `UserInput`, and other replayable entries share a single ordering source.
This should make a fresh session produce `SegmentRotated` first and then `UserMessage` for the committed input, preserving the first user message in the TUI after rotation.
## Requirements / invariants for implementation
- Do not fix this by adding a TUI-only fake/pending user block after rotation.
- Do not reintroduce local optimistic user blocks in `App::method_for_run()`.
- The displayed message should correspond to `LogEntry::UserInput` / persisted history.
- Existing session attach/restore must continue to replay `UserInput` from `Snapshot.entries`.
- 2nd and later sends must continue to display normally.
- Running-state queued input should display when the queued run is actually accepted/committed, not merely when typed.
- Composer input history should remain TUI-local and unaffected.
## Suggested validation
- Add or update a Pod/session-log event ordering test for a fresh session's first `Method::Run`, asserting the live event stream makes the committed user message visible after the initial segment rotation.
- Add or update TUI app/view-model tests if available:
- Current problematic ordering `UserMessage` then `SegmentRotated` clears the block.
- Corrected ordering `SegmentRotated` then `UserMessage` leaves the block visible.
- Confirm snapshot restore still creates user blocks from `LogEntry::UserInput`.
- Run focused tests for `pod` / `tui` crates as appropriate, plus formatting/checks that are reasonably scoped to the touched code.

View File

@ -0,0 +1,23 @@
## External review summary
Status: approved
Reviewer inspected the ticket, investigation artifact, and implementation commit `501dcc9 fix: show initial TUI user message` in branch `tui-new-session-first-message-missing`.
Findings:
- The implementation matches the ticket intent and investigation result.
- The controller-side pre-run optimistic `Event::UserMessage` broadcast is removed from the authoritative path.
- Live `Event::UserMessage` emission is tied to persisted `LogEntry::UserInput` / session-log-derived ordering.
- Fresh sessions should now see initial `SegmentRotated` before the committed user message event, preserving the first message in the TUI view.
- The fix does not use a TUI-only fake/pending message.
- No unnecessary protocol/schema expansion was found.
- Existing snapshot restore, later sends, queued input, and composer input history did not show obvious regressions from the diff.
Validation reviewed:
- `cargo fmt`
- `cargo test -p pod`
- `cargo test -p tui`
Parent decision needed: none.

View File

@ -0,0 +1,28 @@
---
id: 20260601-013132-tui-new-session-first-message-missing
slug: tui-new-session-first-message-missing
title: TUI: first message missing when starting a new session
status: closed
kind: bug
priority: P1
labels: [tui, session, display]
created_at: 2026-06-01T01:31:32Z
updated_at: 2026-06-01T02:23:11Z
assignee: null
legacy_ticket: null
---
## Issue
TUI で新しいセッションを開始して会話を始めたとき、ユーザーが最初に送ったメッセージが会話ビューに表示されないことがある。
この問題は、ユーザー入力が Pod / Worker に届いて処理されているかどうかとは独立に、少なくとも TUI の表示上「最初の user message が欠落したように見える」不具合として扱う。新規セッション開始時の初回 turn だけで起きる可能性が高く、既存セッションへの追加発話や 2 通目以降の表示と異なる初期化順序・履歴同期・描画更新経路が疑わしい。
## Requirements
- 新しいセッションで最初のユーザーメッセージを送信した直後、そのメッセージが TUI の会話ビューに表示されること。
- 表示は、Pod / Worker の履歴に記録された user message と対応していること。表示だけの一時的な偽メッセージで履歴不整合を隠さないこと。
- 既存セッションに attach / restore した場合の履歴表示を壊さないこと。
- 2 通目以降の通常送信、running 中の after-run queue、既存の composer 入力履歴の挙動を変えないこと。
- 原因が TUI 側の view model 初期化、Pod attach / run 開始時の snapshot、history append、または描画更新のどこにあるかを調査で切り分けること。
- 修正に進む場合は、初回メッセージ表示を再現・検証できるテストまたは明確な手動確認手順を残すこと。

View File

@ -0,0 +1 @@
Merged branch tui-new-session-first-message-missing via merge commit ff27917 after external review approval. Validation on develop passed: cargo fmt --check, cargo test -p pod, cargo test -p tui, cargo check -p insomnia, ./tickets.sh doctor.

View File

@ -0,0 +1,118 @@
<!-- event: create author: tickets.sh at: 2026-06-01T01:31:32Z -->
## Created
Created by tickets.sh create.
---
<!-- event: plan author: hare at: 2026-06-01T02:00:33Z -->
## Plan
## Investigation summary
Read-only investigation found that the most likely cause is live-event ordering between `Event::UserMessage` and the first `Event::SegmentRotated` in a fresh session.
Observed flow:
1. TUI submit path calls `App::submit_input()` / `method_for_run()` and sends `Method::Run { input }`.
2. `crates/pod/src/controller.rs` handles `Method::Run` and currently broadcasts `Event::UserMessage { segments }` before calling `pod.run(input)`.
3. TUI receives `Event::UserMessage` and appends a `TurnHeader` / `UserMessage` block.
4. `Pod::run()` calls `prepare_for_run()` / `ensure_segment_head()`.
5. In a fresh session, `entries_written == 0`, so `LogEntry::SegmentStart` is committed only at this point.
6. `crates/pod/src/segment_log_sink.rs` converts `SegmentStart` into `Event::SegmentRotated`.
7. TUI handles `SegmentRotated` by clearing blocks through `reset_for_rotation()` and replaying `SegmentStart.history`.
8. The current `UserInput` has not been committed yet, so `SegmentStart.history` does not contain the first user message.
9. Later `LogEntry::UserInput` is committed, but the live path does not emit `Event::UserMessage` from that commit, so the cleared user block is not restored in the live TUI view.
Likely affected files / functions:
- `crates/tui/src/app.rs`
- `App::submit_input()`
- `method_for_run()`
- `App::handle_pod_event(Event::UserMessage)`
- `App::handle_pod_event(Event::SegmentRotated)`
- `reset_for_rotation()`
- `crates/pod/src/controller.rs`
- `Method::Run` branch
- `crates/pod/src/pod.rs`
- `Pod::run()`
- `prepare_for_run()`
- `ensure_segment_head()`
- `crates/pod/src/segment_log_sink.rs`
- session-log-derived live event conversion
## Implementation intent
Move the live `Event::UserMessage` authority away from the controller's pre-`pod.run()` optimistic broadcast and toward the persisted `LogEntry::UserInput` commit path.
Preferred shape:
- Remove the controller-side `Event::UserMessage` broadcast that happens before `pod.run(input)`.
- Emit `Event::UserMessage` when `LogEntry::UserInput` is committed.
- Prefer doing this in the session-log-derived event lane, such as `SegmentLogSink`, so `SegmentStart`, `UserInput`, and other replayable entries share a single ordering source.
This should make a fresh session produce `SegmentRotated` first and then `UserMessage` for the committed input, preserving the first user message in the TUI after rotation.
## Requirements / invariants for implementation
- Do not fix this by adding a TUI-only fake/pending user block after rotation.
- Do not reintroduce local optimistic user blocks in `App::method_for_run()`.
- The displayed message should correspond to `LogEntry::UserInput` / persisted history.
- Existing session attach/restore must continue to replay `UserInput` from `Snapshot.entries`.
- 2nd and later sends must continue to display normally.
- Running-state queued input should display when the queued run is actually accepted/committed, not merely when typed.
- Composer input history should remain TUI-local and unaffected.
## Suggested validation
- Add or update a Pod/session-log event ordering test for a fresh session's first `Method::Run`, asserting the live event stream makes the committed user message visible after the initial segment rotation.
- Add or update TUI app/view-model tests if available:
- Current problematic ordering `UserMessage` then `SegmentRotated` clears the block.
- Corrected ordering `SegmentRotated` then `UserMessage` leaves the block visible.
- Confirm snapshot restore still creates user blocks from `LogEntry::UserInput`.
- Run focused tests for `pod` / `tui` crates as appropriate, plus formatting/checks that are reasonably scoped to the touched code.
---
<!-- event: review author: hare at: 2026-06-01T02:23:10Z status: approve -->
## Review: approve
## External review summary
Status: approved
Reviewer inspected the ticket, investigation artifact, and implementation commit `501dcc9 fix: show initial TUI user message` in branch `tui-new-session-first-message-missing`.
Findings:
- The implementation matches the ticket intent and investigation result.
- The controller-side pre-run optimistic `Event::UserMessage` broadcast is removed from the authoritative path.
- Live `Event::UserMessage` emission is tied to persisted `LogEntry::UserInput` / session-log-derived ordering.
- Fresh sessions should now see initial `SegmentRotated` before the committed user message event, preserving the first message in the TUI view.
- The fix does not use a TUI-only fake/pending message.
- No unnecessary protocol/schema expansion was found.
- Existing snapshot restore, later sends, queued input, and composer input history did not show obvious regressions from the diff.
Validation reviewed:
- `cargo fmt`
- `cargo test -p pod`
- `cargo test -p tui`
Parent decision needed: none.
---
<!-- event: close author: hare at: 2026-06-01T02:23:11Z status: closed -->
## Closed
Merged branch tui-new-session-first-message-missing via merge commit ff27917 after external review approval. Validation on develop passed: cargo fmt --check, cargo test -p pod, cargo test -p tui, cargo check -p insomnia, ./tickets.sh doctor.
---

View File

@ -0,0 +1,28 @@
## External review summary
Status: approved
Reviewer inspected the ticket and implementation commit `5886f6a fix: align keys UI with TUI viewport` in branch `tui-keys-inline-viewport-ui`.
Findings:
- The implementation aligns `insomnia keys` with the existing inline viewport convention by using `Viewport::Inline(...)`.
- Styling and layout are closer to the existing TUI viewport/list conventions.
- Key management semantics remain unchanged for `SecretStore::set`, `SecretStore::delete`, and `list_ids`.
- Stored secret values are not displayed.
- Value entry is rendered with bullets.
- `KeysApp` / `Action::Set` debug output redacts the secret value.
- Focused tests cover that raw editing values are not rendered.
Non-blocking follow-ups:
- Shared inline viewport helpers/style constants may be worth extracting if more similar screens appear.
- Display-width-aware cursor positioning can be considered later if Unicode-width behavior becomes important.
Validation reviewed:
- `cargo fmt --check`
- `cargo test -p tui keys::tests`
- `cargo check -p insomnia`
Parent decision needed: none.

View File

@ -0,0 +1,38 @@
---
id: 20260601-020202-tui-keys-inline-viewport-ui
slug: tui-keys-inline-viewport-ui
title: TUI: align insomnia keys UI with inline viewport style
status: closed
kind: task
priority: P2
labels: [tui, keys, ui]
created_at: 2026-06-01T02:02:02Z
updated_at: 2026-06-01T02:23:12Z
assignee: null
legacy_ticket: null
---
## Issue
`insomnia keys` の UI が、現在の TUI 実装全体の見た目・操作感から外れている。特に `tui -r` の Pod/session selection で使っているような inline viewport 型の表示・操作と比べて一貫性が弱く、同じプロダクト内の画面として揃っていない。
このチケットでは、`insomnia keys` の UI を TUI 全体の styling / layout / navigation conventions に合わせる。独自 UI を増やすのではなく、既存 TUI の inline viewport / list / selection / actionbar / key hint の概念に寄せる。
## Requirements
- `insomnia keys` の UI を、`tui -r` のときのような inline viewport 型の画面として再設計・実装すること。
- 色、枠線、余白、選択行、フォーカス、ヘルプ表示、action/status 表示などの styling を、既存 TUI 実装全体の conventions と揃えること。
- key 一覧・選択・詳細表示・操作説明が、他の TUI 画面と同じ mental model で使えること。
- 既存の key 管理機能・保存形式・secret handling の意味論を変えないこと。
- secret 値そのものを不用意に画面、ログ、diagnostics、session history、ticket、test snapshot に出さないこと。
- CLI / non-interactive な key 操作がある場合、それらの既存挙動を壊さないこと。
- TUI 共通部品や style helper が既にある場合は再利用し、`insomnia keys` 専用の重複 styling を増やさないこと。
- 実装前に、現在の `insomnia keys` UI がどの crate/module にあり、`tui -r` inline viewport がどの部品で構成されているかを調査してから修正方針を決めること。
## Acceptance criteria
- `insomnia keys` の画面が、既存 TUI の inline viewport 画面と視覚的・操作的に一貫している。
- key 一覧と選択中 item の表示が、既存の list/viewport styling と同じ規則で描画される。
- 操作キーやヘルプ表示が、他の TUI 画面と同じ提示方法になっている。
- secret 値が accidental に露出しないことを確認している。
- 既存の key 管理フローが regression していないことを、手動確認または focused test で説明できる。

View File

@ -0,0 +1 @@
Merged branch tui-keys-inline-viewport-ui via merge commit dbb47e0 after external review approval. Validation on develop passed: cargo fmt --check, cargo test -p pod, cargo test -p tui, cargo check -p insomnia, ./tickets.sh doctor.

View File

@ -0,0 +1,52 @@
<!-- event: create author: tickets.sh at: 2026-06-01T02:02:02Z -->
## Created
Created by tickets.sh create.
---
<!-- event: review author: hare at: 2026-06-01T02:23:11Z status: approve -->
## Review: approve
## External review summary
Status: approved
Reviewer inspected the ticket and implementation commit `5886f6a fix: align keys UI with TUI viewport` in branch `tui-keys-inline-viewport-ui`.
Findings:
- The implementation aligns `insomnia keys` with the existing inline viewport convention by using `Viewport::Inline(...)`.
- Styling and layout are closer to the existing TUI viewport/list conventions.
- Key management semantics remain unchanged for `SecretStore::set`, `SecretStore::delete`, and `list_ids`.
- Stored secret values are not displayed.
- Value entry is rendered with bullets.
- `KeysApp` / `Action::Set` debug output redacts the secret value.
- Focused tests cover that raw editing values are not rendered.
Non-blocking follow-ups:
- Shared inline viewport helpers/style constants may be worth extracting if more similar screens appear.
- Display-width-aware cursor positioning can be considered later if Unicode-width behavior becomes important.
Validation reviewed:
- `cargo fmt --check`
- `cargo test -p tui keys::tests`
- `cargo check -p insomnia`
Parent decision needed: none.
---
<!-- event: close author: hare at: 2026-06-01T02:23:12Z status: closed -->
## Closed
Merged branch tui-keys-inline-viewport-ui via merge commit dbb47e0 after external review approval. Validation on develop passed: cargo fmt --check, cargo test -p pod, cargo test -p tui, cargo check -p insomnia, ./tickets.sh doctor.
---

View File

@ -0,0 +1,56 @@
---
id: 20260601-021104-tui-composer-history-persistence
slug: tui-composer-history-persistence
title: TUI: persist composer input recall history per workspace
status: open
kind: task
priority: P2
labels: [tui, composer, history, persistence]
created_at: 2026-06-01T02:11:04Z
updated_at: 2026-06-01T02:11:04Z
assignee: null
legacy_ticket: null
---
## Issue
TUI composer では上下キーで過去に送信・queue した入力を recall できるが、現在の履歴は TUI-local な揮発状態に留まっている。新しく TUI を起動し直した後も、同じ workspace で使った composer input history を呼び出せるようにしたい。
既存決定として、composer input history recall は Pod protocol / transcript / session history を変更しない TUI-local editing affordance である。この意味論は維持したまま、保存先だけを user data として永続化する。
## Storage decision
永続化先は workspace 配下の `./.insomnia/` ではなく、ユーザー data dir既定では `~/.insomnia`、実装上は既存の data-dir 解決に従う)を使う方針とする。
理由:
- composer input history は個人の操作履歴であり、project-authored asset ではない。
- 入力には secret / private context が混ざり得るため、workspace に書くと git 追跡・共有・公開監査のリスクが上がる。
- `./.insomnia/` は workflow / knowledge / manifest assets など project/workspace 側の明示的な資産に寄せ、生成された個人履歴は user data 側に置く方が境界が明確。
- 「どの workspace の履歴か」は、user data 側で workspace identitycanonical workspace root / git root などから作る stable keyと display metadata を持てば表現できる。
## Requirements
- 上下キーで呼び出す composer input history を、TUI 再起動後も利用できるよう永続化すること。
- 履歴は workspace ごとに分離すること。別 workspace の入力履歴が通常操作で混ざらないこと。
- 保存先は既存の user data dir 配下にすること。デフォルト表示としては `~/.insomnia` 相当だが、実装は data-dir override / path resolution に従うこと。
- `./.insomnia/` には composer history を作成しないこと。
- 保存 record は、どの workspace の履歴か判別できる metadata / key を持つこと。
- 既存の TUI-local / non-destructive recall semantics を維持すること。
- Pod protocol を変えない。
- transcript / session history を mutate しない。
- recalled input は、ユーザーが再送信するまでは conversation state に影響しない。
- 入力は typed `Segment` vector として保存し、structured input の recall を壊さないこと。
- non-blank input のみ保存し、連続重複を抑止し、履歴件数は bounded にすること。既存挙動の 100 件 bound を尊重する。
- secret 値が混ざり得る前提で、保存内容を diagnostics / logs / tickets / tests snapshot / model context に不用意に出さないこと。
- 破損した履歴ファイルがあっても TUI startup を致命的に壊さず、必要なら warning と空履歴 fallback にすること。
- 既存の multiline cursor navigation、Up/Down browse、draft restore、edit-on-recall の挙動を regression させないこと。
## Acceptance criteria
- 同じ workspace で TUI を再起動しても、以前送信した composer input を上下キーで recall できる。
- 別 workspace では履歴が分離される。
- workspace 配下に新しい composer history file が作られない。
- user data dir 配下に workspace-scoped な composer history が保存される。
- Pod session log / Worker history / transcript には、recall 操作だけでは何も追加されない。
- focused test または明確な手動確認手順で、永続化・workspace 分離・既存 recall semantics を検証できる。

View File

@ -0,0 +1,7 @@
<!-- event: create author: tickets.sh at: 2026-06-01T02:11:04Z -->
## Created
Created by tickets.sh create.
---