Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/architecture/cli-product-line-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,7 @@ CLI-P1 应提供:
- TUI 只维护 composer 文本、候选框和结构化引用位置。按 Session 绑定的搜索、路径/类型/行范围校验、持久化与历史恢复由 Agent Runtime 端口和 Core owner 负责;Core 不提前读取文件内容,Agent 仍通过既有 Read/Glob 工具进入权限、取消和审计路径。远程工作区当前返回明确的不可用原因,不回退到本地文件系统。
- OpenCode 对齐的 `/editor`、`/copy`、`/export` 只提供这些命令名,不增加 alias 或默认快捷键。三者从 TUI 当前 `ChatState`/composer 做本地投影,不新增 Runtime operation、IPC 消息或通用 Export/Process port。`/editor` 与 export 的 editor 路径仅使用 `VISUAL` 后 `EDITOR`,由用户提供阻塞等待参数;CLI 在主循环渲染 loading 状态后临时释放 terminal guard,编辑结束后完整重建 raw mode、alternate screen、mouse 与 bracketed paste。编辑失败、非零退出或空文件不替换原草稿;重接管失败时退出而不在半初始化 TUI 中继续。结构化 `@` 引用只在标记唯一或重复标记数量精确匹配时重定位,否则降级为普通文本。
- `/copy` 固定排除 reasoning、工具输入/结果和 TUI 本地 System 通知,并限制在 Idle,避免慢剪贴板 helper 阻塞活动 Turn 的事件流;`/export` 的专用表单可显式加入 reasoning/工具详情、选择保存及 editor review。导出根目录是 CLI 客户端启动目录,不使用 Remote/Shared Runtime 的 workspace;空值、绝对路径、Windows root/prefix 与 `..` 被拒绝。首次发布使用原子 create-new,竞争出现的文件会回到二次确认,确认后才使用 `services-core` 的严格 UTF-8 原子替换。剪贴板复用本地系统 provider 的平台诊断。Shared TUI 仅共享已有 transcript 事实,文件、剪贴板、临时文件与 editor 进程始终留在客户端。
- OpenCode 对齐的 `/timeline` 只列出当前 `ChatState` 中的用户消息并按最新优先展示,方向键移动时预览对应位置,Enter 保留跳转结果;不复制 `/fork`、`/undo` 或 `/copy` 的业务动作。时间线与 fork 复用同一个会话点选择器,跳转与常规滚动复用逐消息 render cache 和行布局,不新增 transcript 副本、Runtime operation 或 IPC 消息。`/timestamps`、`/toggle-timestamps`、`/thinking`、`/toggle-thinking` 严格沿用 OpenCode 入口;工具详情只通过 command palette 切换,不增加 slash alias。三项默认展示策略由 CLI 本地配置持久化,View 仅消费配置投影;单个 thinking/tool block 使用同一套相对默认值的 disclosure override。Embedded 与 Shared TUI 行为一致,活动 Turn 中仍可只读导航和切换展示。
- 图片附件沿用 OpenCode 的 paste 入口和 `[Image N]` 占位符,不新增 slash command、alias 或默认快捷键。Startup 与 Chat 共享同一个 composer draft、图片读取器和 source-range 协调器;PNG/JPEG/GIF/WebP 在粘贴时读取、按真实格式解码并快照为不可变字节,单张上限 20 MiB、单次最多 5 张,只保留文件名而不把绝对路径交给 Runtime。文件读取与剪贴板 PNG 编码在写入期间受硬上限约束,文件解码分配上限为 64 MiB;系统剪贴板 provider 在返回前已完成平台格式解码,BitFun 只能在返回后校验像素尺寸并限制 PNG 编码结果,不能把该 provider 的预解码宣称为受自身上限控制。提交时才投影到已有 `AgentInputAttachment::remote_image`,不新增 Runtime operation、图片专用会话 owner 或第二套发送路径。本地历史、删除重编号、`/editor` 和同一 TUI 进程内按 Session 隔离的成功提交 undo/redo 栈复用原草稿字节,切换 Session 不会混用或丢失关联;输入历史与成功提交栈各自全局最多保留 50 条草稿,并共享 200 MiB 历史图片预算。占位符被删除、变得歧义、超出历史预算或无法与成功提交栈安全关联时只丢弃对应结构化元数据。Shared IPC 的请求帧有界,因此 Shared TUI 在 paste 和提交两层显式拒绝图片并保留草稿,不把 data URL 填入 IPC。
- 对话 checkpoint 与工作区 checkpoint 的独立事实;rewind 必须明确选择只回退对话、只回退工作区或两者。
- 后台 Agent/工具/工作流的状态、取消和结果回收,不允许无结果的隐式 detached task。
Expand Down
9 changes: 9 additions & 0 deletions src/apps/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,9 @@ The Embedded and Shared TUI use the same session command names:
- `/fork` opens an OpenCode-compatible fork dialog. `Full session` copies through the latest
persisted turn; choosing a previous user prompt forks immediately before that turn and copies the
prompt into the composer without sending it. Forking requires an idle session.
- `/timeline` opens the OpenCode-compatible user-message timeline, newest first. Moving the
selection previews that location in the current transcript; Enter keeps the selected message in
view. Timeline navigation is read-only and remains available while a turn is running.
- `/status` opens a transient view of current session, runtime, workspace, approval, and latest
primary-model request facts observed by this TUI. It is not a cumulative usage report; use
`/usage` for cumulative session usage in Embedded TUI.
Expand All @@ -102,6 +105,12 @@ The Embedded and Shared TUI use the same session command names:
leave the existing draft unchanged; structured `@` references are retained only when their
edited markers remain unambiguous. A terminal reacquisition failure exits instead of continuing
in a partially initialized TUI.
- `/timestamps` (`/toggle-timestamps`) and `/thinking` (`/toggle-thinking`) use OpenCode's command
names to toggle message timestamps and reasoning-block visibility. Tool-detail visibility is a
command-palette action only, matching OpenCode rather than introducing another slash command.
Timestamps and thinking default to hidden; tool details default to shown. These client-local
preferences are saved in CLI configuration, while clicking an individual reasoning or tool block
still overrides its current presentation.
- `/copy` copies a safe Markdown snapshot of the visible User/Assistant transcript. Reasoning and
tool payloads are excluded by default; local System notices are never exported. Copy is idle-only
so a slow platform clipboard helper cannot stall an active Turn's event stream.
Expand Down
102 changes: 101 additions & 1 deletion src/apps/cli/src/actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ pub(crate) enum ActionHandler {
AddModel,
NewSession,
Sessions,
Timeline,
ForkSession,
UndoSession,
RedoSession,
Expand All @@ -92,6 +93,9 @@ pub(crate) enum ActionHandler {
CompactSession,
Usage,
Editor,
ToggleTimestamps,
ToggleThinking,
ToggleToolDetails,
CopyTranscript,
ExportTranscript,
ToggleAutoApprove,
Expand Down Expand Up @@ -127,7 +131,7 @@ pub(crate) fn shared_tui_image_attachment_error() -> String {
format!("Image attachments are unavailable in Shared TUI. {SHARED_TUI_EMBEDDED_HANDOFF}.")
}
pub(crate) const SHARED_TUI_HELP_NOTE: &str =
"Shared TUI: start with `bitfun chat --shared`. Multiple TUI processes reuse one workspace Runtime, while each TUI controls at most one Session and each Session has one controller. Use `/sessions` and Ctrl+D to delete an idle, non-current Session; use `/fork` to branch the current idle Session, `/rename <name>` to rename it, `/compact` to compact its context, `/diff` to review workspace changes, `/agent`, Tab, or Shift+Tab to change its Agent mode, `/models` to change its model, and `/reload [skills|instructions]` to refresh declarative context for the next message. Model configuration, Agent/Subagent management, MCP, extension, account-sync, usage, and other management remain Embedded. Exit all Shared TUI clients and wait up to 30 seconds before returning to default Embedded `bitfun chat`.";
"Shared TUI: start with `bitfun chat --shared`. Multiple TUI processes reuse one workspace Runtime, while each TUI controls at most one Session and each Session has one controller. Use `/sessions` and Ctrl+D to delete an idle, non-current Session; use `/timeline` to navigate user messages, `/fork` to branch the current idle Session, `/rename <name>` to rename it, `/compact` to compact its context, `/diff` to review workspace changes, `/agent`, Tab, or Shift+Tab to change its Agent mode, `/models` to change its model, and `/reload [skills|instructions]` to refresh declarative context for the next message. Model configuration, Agent/Subagent management, MCP, extension, account-sync, usage, and other management remain Embedded. Exit all Shared TUI clients and wait up to 30 seconds before returning to default Embedded `bitfun chat`.";

impl ActionHandler {
pub(crate) const fn available_in_shared_tui(self, context: ActionContext) -> bool {
Expand All @@ -138,6 +142,7 @@ impl ActionHandler {
| Self::SelectTheme
| Self::NewSession
| Self::Sessions
| Self::Timeline
| Self::ForkSession
| Self::UndoSession
| Self::RedoSession
Expand All @@ -148,6 +153,9 @@ impl ActionHandler {
| Self::WorkspaceDiff
| Self::CompactSession
| Self::Editor
| Self::ToggleTimestamps
| Self::ToggleThinking
| Self::ToggleToolDetails
| Self::CopyTranscript
| Self::ExportTranscript
| Self::ToggleAutoApprove
Expand Down Expand Up @@ -378,6 +386,21 @@ static ACTION_SPECS: &[ActionSpec] = &[
shortcut_label: None,
slash_on_startup: true,
},
ActionSpec {
id: "timeline",
name: "Timeline",
aliases: &["/timeline"],
description: "Jump to a user message in the current session",
contexts: CHAT,
availability: ActionAvailability::Always,
handler: ActionHandler::Timeline,
default_bindings: &[],
fallback_bindings: &[],
shortcut_field: None,
palette: palette("Session", false),
shortcut_label: None,
slash_on_startup: false,
},
ActionSpec {
id: "rename_session",
name: "Rename session",
Expand Down Expand Up @@ -648,6 +671,51 @@ static ACTION_SPECS: &[ActionSpec] = &[
shortcut_label: None,
slash_on_startup: false,
},
ActionSpec {
id: "toggle_timestamps",
name: "Timestamps",
aliases: &["/timestamps", "/toggle-timestamps"],
description: "Toggle message timestamps",
contexts: CHAT,
availability: ActionAvailability::Always,
handler: ActionHandler::ToggleTimestamps,
default_bindings: &[],
fallback_bindings: &[],
shortcut_field: None,
palette: palette("Session", false),
shortcut_label: None,
slash_on_startup: false,
},
ActionSpec {
id: "toggle_thinking",
name: "Thinking blocks",
aliases: &["/thinking", "/toggle-thinking"],
description: "Toggle thinking block visibility",
contexts: CHAT,
availability: ActionAvailability::Always,
handler: ActionHandler::ToggleThinking,
default_bindings: &[],
fallback_bindings: &[],
shortcut_field: None,
palette: palette("Session", false),
shortcut_label: None,
slash_on_startup: false,
},
ActionSpec {
id: "toggle_tool_details",
name: "Tool details",
aliases: &[],
description: "Toggle tool detail visibility",
contexts: CHAT,
availability: ActionAvailability::Always,
handler: ActionHandler::ToggleToolDetails,
default_bindings: &[],
fallback_bindings: &[],
shortcut_field: None,
palette: palette("Session", false),
shortcut_label: None,
slash_on_startup: false,
},
ActionSpec {
id: "copy_transcript",
name: "Copy transcript",
Expand Down Expand Up @@ -2904,4 +2972,36 @@ mod tests {
assert!(copy.handler.available_in_shared_tui(ActionContext::Chat));
assert!(export.handler.available_in_shared_tui(ActionContext::Chat));
}

#[test]
fn opencode_navigation_and_presentation_actions_keep_exact_entrypoints() {
let timeline =
action_for_alias("/timeline", ActionContext::Chat).expect("/timeline action");
let timestamps =
action_for_alias("/timestamps", ActionContext::Chat).expect("/timestamps action");
let timestamps_alias = action_for_alias("/toggle-timestamps", ActionContext::Chat)
.expect("/toggle-timestamps action");
let thinking =
action_for_alias("/thinking", ActionContext::Chat).expect("/thinking action");
let thinking_alias = action_for_alias("/toggle-thinking", ActionContext::Chat)
.expect("/toggle-thinking action");
let tool_details = action_by_id("toggle_tool_details", ActionContext::Chat)
.expect("tool details palette action");

assert_eq!(timeline.id, "timeline");
assert_eq!(timeline.aliases, ["/timeline"]);
assert_eq!(timestamps.id, "toggle_timestamps");
assert_eq!(timestamps.aliases, ["/timestamps", "/toggle-timestamps"]);
assert_eq!(timestamps.id, timestamps_alias.id);
assert_eq!(thinking.id, "toggle_thinking");
assert_eq!(thinking.aliases, ["/thinking", "/toggle-thinking"]);
assert_eq!(thinking.id, thinking_alias.id);
assert!(tool_details.aliases.is_empty());
for action in [timeline, timestamps, thinking, tool_details] {
assert_eq!(action.palette.map(|palette| palette.group), Some("Session"));
assert!(action.available(ActionState::chat(false, false)));
assert!(action.available(ActionState::chat(true, false)));
assert!(action.handler.available_in_shared_tui(ActionContext::Chat));
}
}
}
94 changes: 85 additions & 9 deletions src/apps/cli/src/chat_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,25 @@ pub(crate) struct SessionForkPoint {
pub timestamp: SystemTime,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct SessionTimelinePoint {
pub message_id: String,
pub prompt: String,
pub timestamp: SystemTime,
}

fn visible_message_text(message: &ChatMessage) -> String {
message
.flow_items
.iter()
.filter_map(|item| match item {
FlowItem::Text { content, .. } => Some(content.as_str()),
FlowItem::Thinking { .. } | FlowItem::Tool { .. } => None,
})
.collect::<Vec<_>>()
.join("\n")
}

// ============ Chat Metadata ============

/// Statistics for the current chat session
Expand Down Expand Up @@ -483,15 +502,7 @@ impl ChatState {
.filter(|message| message.role == MessageRole::User)
.filter_map(|message| {
let turn_id = message.turn_id.clone()?;
let prompt = message
.flow_items
.iter()
.filter_map(|item| match item {
FlowItem::Text { content, .. } => Some(content.as_str()),
FlowItem::Thinking { .. } | FlowItem::Tool { .. } => None,
})
.collect::<Vec<_>>()
.join("\n");
let prompt = visible_message_text(message);
(!prompt.is_empty()).then_some(SessionForkPoint {
message_id: message.id.clone(),
turn_id,
Expand All @@ -502,6 +513,23 @@ impl ChatState {
.collect()
}

/// User messages eligible for OpenCode-compatible `/timeline`, newest first.
pub(crate) fn session_timeline_points(&self) -> Vec<SessionTimelinePoint> {
self.messages
.iter()
.rev()
.filter(|message| message.role == MessageRole::User)
.filter_map(|message| {
let prompt = visible_message_text(message);
(!prompt.is_empty()).then_some(SessionTimelinePoint {
message_id: message.id.clone(),
prompt,
timestamp: message.timestamp,
})
})
.collect()
}

pub(crate) fn latest_user_message_id(&self) -> Option<String> {
self.messages
.iter()
Expand Down Expand Up @@ -1967,6 +1995,54 @@ mod tests {
assert_eq!(points[1].prompt, "First prompt");
}

#[test]
fn timeline_points_match_opencode_user_message_order_without_needing_turn_ids() {
let transcript = SessionTranscript {
session_id: "session-1".to_string(),
messages: vec![
TranscriptMessage {
id: Some("user-1".to_string()),
role: "user".to_string(),
turn_id: None,
timestamp_ms: Some(1_000),
content: TranscriptContent::Text("First\nprompt".to_string()),
},
TranscriptMessage {
id: Some("assistant-1".to_string()),
role: "assistant".to_string(),
turn_id: Some("turn-1".to_string()),
timestamp_ms: Some(2_000),
content: TranscriptContent::Text("Answer".to_string()),
},
TranscriptMessage {
id: Some("user-2".to_string()),
role: "user".to_string(),
turn_id: Some("turn-2".to_string()),
timestamp_ms: Some(3_000),
content: TranscriptContent::Multimodal {
text: "Second prompt".to_string(),
image_count: 1,
},
},
],
};
let state = ChatState::from_session_transcript(
"session-1".to_string(),
"Session".to_string(),
"agentic".to_string(),
None,
&transcript,
);

let points = state.session_timeline_points();

assert_eq!(points.len(), 2);
assert_eq!(points[0].message_id, "user-2");
assert_eq!(points[0].prompt, "Second prompt");
assert_eq!(points[1].message_id, "user-1");
assert_eq!(points[1].prompt, "First\nprompt");
}

#[test]
fn transcript_history_merges_tool_results_into_the_rendered_tool_card() {
let transcript = SessionTranscript {
Expand Down
Loading
Loading