diff --git a/.agents/notes/implemented/feature/2026-10-24-agent-transcript-images-and-task-notifications.md b/.agents/notes/implemented/feature/2026-10-24-agent-transcript-images-and-task-notifications.md index bdda2e035..7795ef645 100644 --- a/.agents/notes/implemented/feature/2026-10-24-agent-transcript-images-and-task-notifications.md +++ b/.agents/notes/implemented/feature/2026-10-24-agent-transcript-images-and-task-notifications.md @@ -62,9 +62,14 @@ Claude Agent SDK 的 `getSessionMessages` 只返回当前 parent 链;压缩边 - 只读,只打开 `projects/*/<会话 id>.jsonl`;账号目录里的登录信息和其他文件不触碰;会话 id 只接受字母、数字、`-`、`_`。 - 配置目录按所选 Claude 账号解析(账号目录、`CLAUDE_CONFIG_DIR`、`~/.claude`),不写入任何文件,不缓存。 -- 超过 128 MiB 的记录文件不读取;最多发出 30000 条更新;工具输入输出每段截断到 4000 字符;子代理侧链和 `isMeta` 行跳过,图片不回放。 +- 超过 128 MiB 的记录文件不读取;最多发出 30000 条更新;工具输入输出每段截断到 4000 字符;子代理侧链和 `isMeta` 行跳过。 +- 用户消息里的内联 base64 截图随所在消息回放(带同一个 `messageId`,macOS 并入原气泡);`url` 来源和空数据跳过, + 超过客户端 10 MB 解码上限(base64 约 13.3 MB)的不发送,每条消息的数量与解码由 macOS `AgentImage` 限制。其他图片不回放。 - 文件格式由 Claude Code 拥有。解析失败或格式变化时只是不显示更早的历史,不影响适配器自身的回放。 - Codex 的历史仍由其官方 App Server 提供,不受此例外影响。 +- 4000 字符的截断只用于工具详情预览。Claude Desktop 的卡片、计划和回复由工具输入生成 + (`wrap_up`、`update_plan`、`ccd_*`、`visualize` 等),这些输入按 256K 字符保留;否则压缩前的可视化会被 + 截坏,任务提示也只会把截断后的内容填进新对话。 不要这样做:不要把前文合并进适配器已回放的片段,也不要改写或重新压缩 Claude 自己的记录文件。 @@ -78,9 +83,38 @@ Claude Desktop 给 Claude Code 挂了自己的 `ccd_turn` MCP 服务,模型在 在 Lithe 里恢复这类会话时,整段对话只剩折叠的工具调用,看起来像历史没有回放。 宿主(`claude_desktop.rs`)把 `wrap_up` 的工具调用改写成一条 Agent 回复(总结与提问各一段), -并吞掉该调用后续的 `tool_call_update`;建议回复按钮不显示,因为用户点过的那条会作为下一条用户消息出现。 -改写同时作用于适配器的回放和上面的前文转换,Windows 共用同一宿主。Lithe 自己不提供 `ccd_turn` 工具, -所以只影响从 Desktop 带过来的历史。 +并吞掉该调用后续的 `tool_call_update`。改写同时作用于适配器的回放和上面的前文转换,Windows 共用同一宿主。 +Lithe 自己不提供 Desktop 的 MCP 服务,所以这些只影响从 Desktop 带过来的历史;CLI 会话不受影响。 + +Desktop 的其他机制同样在宿主里识别,结果分两层交给客户端: + +1. **可读的工具行**:每个 Desktop 工具(`ccd_*`、`visualize`、`terminal`、`Claude_Browser`、 + `computer-use`、`scheduled-tasks`,以及按 UUID 命名的 claude.ai 连接器)都换成英文标题和合适的 + `kind`,例如 “Chapter: …”“Computer use: app screenshot”“Connector: read”。不认识卡片的客户端 + (目前是 Windows)也不会再看到 `mcp__ccd_session__mark_chapter` 这类原名。打开窗格、在编辑器打开、 + 在 Finder 显示的调用改成 `read` 并带 `locations`,点击可以打开文件。 +2. **卡片描述**:有交互含义的调用在 `_meta.lithe.claudeDesktop` 里附一张卡片,macOS 原生绘制: + 章节(`mark_chapter`)显示为分隔线;建议回复(`wrap_up.actions`,宿主另发一个 `#next` 调用) + 显示为按钮,只在它是最后一条消息且不在回复中时出现;建议任务、启动会话、移交(`spawn_task`、 + `start_session`、`hand_off_to_session`)显示为任务卡片,撤回(`dismiss_task`)或启动的结果回来后, + 宿主补发一次 `tool_call_update` 改写原卡片状态;PR 绑定、监控、自动合并显示为带链接的一行; + 切换或申请目录显示为一行说明;`show_widget` 显示为可展开的可视化卡片。助手文字里的 + `URL` 由宿主改成 Markdown 链接。 + +卡片只在调用成功后单独显示。历史里很多 Desktop 调用被自动模式分类器拒绝,那些调用在 Desktop 里也没有发生, +所以留在折叠过程里作为失败的工具行。建议回复和任务按钮只把文字填进输入框(任务会先新建对话), +不会自动发送,用户仍然决定是否发出。 + +可视化卡片展开后才创建 `WKWebView`:非持久数据存储,CSP(内容安全策略)`default-src 'none'` +禁止一切网络加载,只允许内联脚本和样式,导航全部取消,链接也不外开。原因:页面脚本可以自己 +`a.click()`,WebKit 把它和用户点击一样报告为 `.linkActivated`,外开会让页面把内容拼进 URL 发往任意地址, +取消导航也撤不回已经调用的 `NSWorkspace.open`。`sendPrompt` 只填输入框,而且只在用户于页面内点击或按键 +之后 2 秒内生效(宿主监听真实输入事件,页面脚本伪造不了),否则页面一展开就能覆盖草稿、抢走焦点。宿主补上 Desktop 文档里的主题变量(`--text-primary`、`--surface-1` 等), +颜色来自当前主题。依赖 CDN 脚本的图表(例如 Chart.js)因此不会渲染,这是有意的取舍: +不让 Agent 写的页面从会话里向外发请求。 + +用户消息里的 ``(可带属性)和 `[Cross-session idle notice]` 等 Desktop 通知 +作为折叠的上下文块显示,不当成用户说的话。 `update_plan` 有整表(`items`)和增量(`done`/`start`/`advance`)两种写法。宿主按会话记住当前列表, 把每次调用改写成一条完整的 ACP `plan` 更新;增量调用之前没有整表时原样保留为工具调用。 @@ -89,6 +123,8 @@ Swift 在加载历史期间把每份计划快照记成一条不带文字的 Agen 没有文字回复的段落也能看出做了什么。 不要这样做:不要在 Swift 或 Windows 前端按工具名再做一份改写,也不要读取或模拟 Desktop 的 MCP 服务。 +卡片字段增加时,先在宿主和 `shared/contracts/application-boundary.md` 里定义,再让客户端解析; +客户端遇到不认识的卡片类型必须退回普通工具行。 ### 不要这样做 @@ -116,6 +152,7 @@ issue #1462 指出的多余复制按钮来自不属于最终回复的过程文 ## 验证 - `./scripts/test-macos.sh --filter AgentImageTests` +- `./scripts/test-macos.sh --filter AgentDesktopCardTests` - `cargo test -p lithe-agent-host claude_earlier_history` - `cargo test -p lithe-agent-host claude_desktop` - `./scripts/verify-agent-notes.sh` @@ -126,3 +163,6 @@ issue #1462 指出的多余复制按钮来自不属于最终回复的过程文 - `macos/Sources/Lithe/Views/Agent/AgentImageViews.swift` - `macos/Sources/Lithe/Views/Agent/AgentToolGroupView.swift` - `rust/lithe-agent-host/src/claude_earlier_history.rs` +- `rust/lithe-agent-host/src/claude_desktop.rs` +- `macos/Sources/LitheAgentConversationModule/Application/AgentDesktopCard.swift` +- `macos/Sources/Lithe/Views/Agent/AgentDesktopCardView.swift` diff --git a/macos/Resources/en.lproj/Localizable.strings b/macos/Resources/en.lproj/Localizable.strings index cfeddaa48..9d93405b4 100644 --- a/macos/Resources/en.lproj/Localizable.strings +++ b/macos/Resources/en.lproj/Localizable.strings @@ -331,6 +331,7 @@ "Ask questions about public GitHub repositories and read their generated documentation." = "Ask questions about public GitHub repositories and read their generated documentation."; "Ask the Agent about this workspace" = "Ask the Agent about this workspace"; "Ask the Agent, Enter to send, Shift-Enter for a new line" = "Ask the Agent, Enter to send, Shift-Enter for a new line"; +"Asked for access to %@" = "Asked for access to %@"; "Assignees" = "Assignees"; "Astro Support" = "Astro Support"; "Attach" = "Attach"; @@ -461,6 +462,7 @@ "ChangeList storage is unavailable. Restore the saved data and reopen the workspace." = "ChangeList storage is unavailable. Restore the saved data and reopen the workspace."; "Changed" = "Changed"; "Changed Files" = "Changed Files"; +"Changed the working directory to %@" = "Changed the working directory to %@"; "Changes" = "Changes"; "Changes apply to the next turn." = "Changes apply to the next turn."; "Changes are written to" = "Changes are written to"; @@ -469,6 +471,7 @@ "Changes requested" = "Changes requested"; "Changing channels requires a full installation and may install an older version. On its first launch, Lithe-Dev copies Stable settings and workspaces if it has no user data. The apps then save settings and caches separately. Some accounts may need sign-in again. Your project files stay in their current locations." = "Changing channels requires a full installation and may install an older version. On its first launch, Lithe-Dev copies Stable settings and workspaces if it has no user data. The apps then save settings and caches separately. Some accounts may need sign-in again. Your project files stay in their current locations."; "Changing channels requires a full installation and may install an older version. Open the selected release page to download its installer. Your channel selection is saved for future updates." = "Changing channels requires a full installation and may install an older version. Open the selected release page to download its installer. Your channel selection is saved for future updates."; +"Chapter: %@" = "Chapter: %@"; "Chat Completions" = "Chat Completions"; "Chat History" = "Chat History"; "Chat options" = "Chat options"; @@ -1445,6 +1448,7 @@ "HTTP is blocked until you explicitly allow it for this provider." = "HTTP is blocked until you explicitly allow it for this provider."; "HTTP is disabled for this provider. Enable the insecure HTTP option or use HTTPS." = "HTTP is disabled for this provider. Enable the insecure HTTP option or use HTTPS."; "HTTP sends the API credential without encryption. Use only a trusted endpoint." = "HTTP sends the API credential without encryption. Use only a trusted endpoint."; +"Handed off" = "Handed off"; "Hard Reset (Discard Changes)" = "Hard Reset (Discard Changes)"; "Haskell Support" = "Haskell Support"; "Head branch" = "Head branch"; @@ -1471,6 +1475,7 @@ "Hide plan" = "Hide plan"; "Hide thinking" = "Hide thinking"; "Hide tool details" = "Hide tool details"; +"Hide visualization" = "Hide visualization"; "Hide worktree repositories" = "Hide worktree repositories"; "High" = "High"; "Highlight words" = "Highlight words"; @@ -1683,6 +1688,7 @@ "Line breakpoint actions" = "Line breakpoint actions"; "Linear" = "Linear"; "Lines kept for scrolling back in new terminal sessions." = "Lines kept for scrolling back in new terminal sessions."; +"Linked this conversation to a pull request" = "Linked this conversation to a pull request"; "Linked worktree" = "Linked worktree"; "Listed in" = "Listed in"; "Lithe" = "Lithe"; @@ -3026,6 +3032,7 @@ "Show thinking" = "Show thinking"; "Show tool details" = "Show tool details"; "Show usages and Git author" = "Show usages and Git author"; +"Show visualization" = "Show visualization"; "Show worktree repositories" = "Show worktree repositories"; "Showing available matches." = "Showing available matches."; "Showing the previous Java entries while the Java language service prepares the project." = "Showing the previous Java entries while the Java language service prepares the project."; @@ -3123,11 +3130,13 @@ "Start debugging" = "Start debugging"; "Start debugging the current Java file" = "Start debugging the current Java file"; "Start from" = "Start from"; +"Start in new conversation" = "Start in new conversation"; "Start service" = "Start service"; "Start the discussion or submit the first review." = "Start the discussion or submit the first review."; "Start the session by sending a message" = "Start the session by sending a message"; "Start this configuration to see its output here." = "Start this configuration to see its output here."; "Start with an action verb, without a type prefix" = "Start with an action verb, without a type prefix"; +"Started in Claude Desktop" = "Started in Claude Desktop"; "Starting" = "Starting"; "Starting Interactive Rebase" = "Starting Interactive Rebase"; "Starting Java language server..." = "Starting Java language server..."; @@ -3188,6 +3197,8 @@ "Submitting review…" = "Submitting review…"; "Subscription accounts" = "Subscription accounts"; "Succeeded" = "Succeeded"; +"Suggested" = "Suggested"; +"Suggested replies" = "Suggested replies"; "Supplementary message cancelled." = "Supplementary message cancelled."; "Supported placeholders: {summary}, {changes}, {testing}, {risks}." = "Supported placeholders: {summary}, {changes}, {testing}, {risks}."; "Svelte Support" = "Svelte Support"; @@ -3545,6 +3556,8 @@ "Turn on Model call statistics in Agent settings and reconnect to see the system prompt, tool definitions and messages." = "Turn on Model call statistics in Agent settings and reconnect to see the system prompt, tool definitions and messages."; "Turn on Model call statistics, reconnect and send a message to see the breakdown." = "Turn on Model call statistics, reconnect and send a message to see the breakdown."; "Turn usage" = "Turn usage"; +"Turned off auto-merge" = "Turned off auto-merge"; +"Turned on auto-merge" = "Turned on auto-merge"; "Turning this off stops every Agent process." = "Turning this off stops every Agent process."; "Turns" = "Turns"; "Type" = "Type"; @@ -3583,6 +3596,7 @@ "Unknown error" = "Unknown error"; "Unknown source" = "Unknown source"; "Unlink" = "Unlink"; +"Unlinked the pull request" = "Unlinked the pull request"; "Unlock Worktree" = "Unlock Worktree"; "Unlock the worktree before removing it." = "Unlock the worktree before removing it."; "Unlocked" = "Unlocked"; @@ -3729,6 +3743,7 @@ "View breakpoints" = "View breakpoints"; "View breakpoints (⌘⇧F8)" = "View breakpoints (⌘⇧F8)"; "View skill" = "View skill"; +"Visualization" = "Visualization"; "Vue Support" = "Vue Support"; "WIP" = "WIP"; "WORKING TREE" = "WORKING TREE"; @@ -3756,6 +3771,8 @@ "Waiting to quit to complete the update." = "Waiting to quit to complete the update."; "Warning" = "Warning"; "Warnings" = "Warnings"; +"Watching pull request checks" = "Watching pull request checks"; +"Watching pull request checks and fixing failures" = "Watching pull request checks and fixing failures"; "Welcome to Lithe" = "Welcome to Lithe"; "Welcome to Lithe Agent" = "Welcome to Lithe Agent"; "What Fetch removes or downloads before you start working." = "What Fetch removes or downloads before you start working."; @@ -3774,6 +3791,7 @@ "Whitespace comparison" = "Whitespace comparison"; "Whole Words" = "Whole Words"; "Window" = "Window"; +"Withdrawn" = "Withdrawn"; "Without Remember in Keychain, Lithe asks every time it connects. A remembered secret is answered only the first time the server asks for it; if it is rejected, Lithe asks you." = "Without Remember in Keychain, Lithe asks every time it connects. A remembered secret is answered only the first time the server asks for it; if it is rejected, Lithe asks you."; "Without it, Lithe asks every time it connects. A remembered secret is answered only the first time the server asks for it; if it is rejected, Lithe asks you." = "Without it, Lithe asks every time it connects. A remembered secret is answered only the first time the server asks for it; if it is rejected, Lithe asks you."; "Work with GitHub repositories, issues, pull requests and Actions. A personal access token is required." = "Work with GitHub repositories, issues, pull requests and Actions. A personal access token is required."; @@ -4106,6 +4124,7 @@ "native.askQuestionsAboutPublicGithubRepositoriesAndReadTheirGeneratedDocumentation" = "Ask questions about public GitHub repositories and read their generated documentation."; "native.askTheAgentAboutThisWorkspace" = "Ask the Agent about this workspace"; "native.askTheAgentEnterToSendShiftEnterForANewLine" = "Ask the Agent, Enter to send, Shift-Enter for a new line"; +"native.askedForAccessTo" = "Asked for access to %1$@"; "native.assignees" = "Assignees"; "native.astroSupport" = "Astro Support"; "native.attach" = "Attach"; @@ -4229,6 +4248,7 @@ "native.changed" = "Changed"; "native.changedFiles" = "Changed Files"; "native.changedOutsideLitheRefreshAndTryAgain" = "“%1$@” changed outside Lithe. Refresh and try again."; +"native.changedTheWorkingDirectoryTo" = "Changed the working directory to %1$@"; "native.changelist" = "ChangeList"; "native.changelistEditingIsUnavailable" = "ChangeList editing is unavailable."; "native.changelistName" = "ChangeList name"; @@ -4241,6 +4261,7 @@ "native.changesRequested" = "Changes requested"; "native.changingChannelsRequiresAFullInstallationAndMayInstallAnOlderVersionOnItsFirste8ccfd1e52" = "Changing channels requires a full installation and may install an older version. On its first launch, Lithe-Dev copies Stable settings and workspaces if it has no user data. The apps then save settings and caches separately. Some accounts may need sign-in again. Your project files stay in their current locations."; "native.changingChannelsRequiresAFullInstallationAndMayInstallAnOlderVersionOpenTheSelda3c463026" = "Changing channels requires a full installation and may install an older version. Open the selected release page to download its installer. Your channel selection is saved for future updates."; +"native.chapter" = "Chapter: %1$@"; "native.charactersUsedByTheInterfaceAndYourFiles" = "characters used by the interface and your files"; "native.chars" = "chars"; "native.chatCompletions" = "Chat Completions"; @@ -5230,6 +5251,7 @@ "native.groovySupport" = "Groovy Support"; "native.group" = "Group"; "native.groups" = "Groups"; +"native.handedOff" = "Handed off"; "native.hardResetDiscardChanges" = "Hard Reset (Discard Changes)"; "native.haskellSupport" = "Haskell Support"; "native.head" = "HEAD"; @@ -5260,6 +5282,7 @@ "native.hideTerminalToolWindow" = "Hide Terminal tool window"; "native.hideThinking" = "Hide thinking"; "native.hideToolDetails" = "Hide tool details"; +"native.hideVisualization" = "Hide visualization"; "native.hideWorktreeRepositories" = "Hide worktree repositories"; "native.high" = "High"; "native.highlightWords" = "Highlight words"; @@ -5581,6 +5604,7 @@ "native.lineColumn" = "[Line] [:column]:"; "native.linear" = "Linear"; "native.linesKeptForScrollingBackInNewTerminalSessions" = "Lines kept for scrolling back in new terminal sessions."; +"native.linkedThisConversationToAPullRequest" = "Linked this conversation to a pull request"; "native.linkedWorktree" = "Linked worktree"; "native.listedIn" = "Listed in"; "native.lithe" = "Lithe"; @@ -6975,6 +6999,7 @@ "native.showToolDetails" = "Show tool details"; "native.showToolbar" = "Show Toolbar"; "native.showUsagesAndGitAuthor" = "Show usages and Git author"; +"native.showVisualization" = "Show visualization"; "native.showWorktreeRepositories" = "Show worktree repositories"; "native.showingAvailableMatches" = "Showing available matches."; "native.showingThePreviousJavaEntriesWhileTheJavaLanguageServicePreparesTheProject" = "Showing the previous Java entries while the Java language service prepares the project."; @@ -7076,6 +7101,7 @@ "native.startDebugging.variant2" = "Start debugging"; "native.startDebuggingTheCurrentJavaFile" = "Start debugging the current Java file"; "native.startFrom" = "Start from"; +"native.startInNewConversation" = "Start in new conversation"; "native.startRebase" = "Start Rebase"; "native.startRecording" = "Start Recording"; "native.startService" = "Start service"; @@ -7084,6 +7110,7 @@ "native.startThisConfigurationToSeeItsOutputHere" = "Start this configuration to see its output here."; "native.startWithAnActionVerbWithoutATypePrefix" = "Start with an action verb, without a type prefix"; "native.startedATaskThatIsStillRunning" = "%1$@ started a task that is still running"; +"native.startedInClaudeDesktop" = "Started in Claude Desktop"; "native.starting" = "Starting"; "native.startingCommitOptional" = "Starting commit (optional)"; "native.startingInteractiveRebase" = "Starting Interactive Rebase"; @@ -7143,6 +7170,8 @@ "native.subjectMaximumLength" = "Subject maximum length"; "native.submittingReview" = "Submitting review…"; "native.succeeded" = "Succeeded"; +"native.suggested" = "Suggested"; +"native.suggestedReplies" = "Suggested replies"; "native.supplementaryMessageCancelled" = "Supplementary message cancelled."; "native.supportedPlaceholdersSummaryChangesTestingRisks" = "Supported placeholders: {summary}, {changes}, {testing}, {risks}."; "native.svelteSupport" = "Svelte Support"; @@ -7506,6 +7535,8 @@ "native.turnOnModelCallStatisticsInAgentSettingsAndReconnectToSeeTheSystemPromptToolDeffd4968e05" = "Turn on Model call statistics in Agent settings and reconnect to see the system prompt, tool definitions and messages."; "native.turnOnModelCallStatisticsReconnectAndSendAMessageToSeeTheBreakdown" = "Turn on Model call statistics, reconnect and send a message to see the breakdown."; "native.turnUsage" = "Turn usage"; +"native.turnedOffAutoMerge" = "Turned off auto-merge"; +"native.turnedOnAutoMerge" = "Turned on auto-merge"; "native.turningThisOffStopsEveryAgentProcess" = "Turning this off stops every Agent process."; "native.turns" = "Turns"; "native.type" = "Type"; @@ -7542,6 +7573,7 @@ "native.unknownError" = "Unknown error"; "native.unknownSource" = "Unknown source"; "native.unlink" = "Unlink"; +"native.unlinkedThePullRequest" = "Unlinked the pull request"; "native.unlockTheWorktreeBeforeRemovingIt" = "Unlock the worktree before removing it."; "native.unlockWorktree" = "Unlock Worktree"; "native.unlocked" = "Unlocked"; @@ -7699,6 +7731,7 @@ "native.viewOptions" = "View Options"; "native.viewSavedChanges" = "View Saved Changes"; "native.viewSkill" = "View skill"; +"native.visualization" = "Visualization"; "native.vmArguments" = "VM arguments"; "native.vmOptions" = "VM options"; "native.vueSupport" = "Vue Support"; @@ -7730,6 +7763,8 @@ "native.warnings" = "Warnings"; "native.wasCreatedOutsideLitheTakeOwnershipBeforeChangingIt" = "“%1$@” was created outside Lithe. Take ownership before changing it."; "native.wasNotFoundInstallItForExampleThenCheckAgain" = "%1$@ was not found. Install it (for example `%2$@`), then check again."; +"native.watchingPullRequestChecks" = "Watching pull request checks"; +"native.watchingPullRequestChecksAndFixingFailures" = "Watching pull request checks and fixing failures"; "native.welcomeToLithe" = "Welcome to Lithe"; "native.welcomeToLitheAgent" = "Welcome to Lithe Agent"; "native.whatDoesThisPullRequestChange" = "What does this pull request change?"; @@ -7749,6 +7784,7 @@ "native.wholeWords" = "Whole Words"; "native.window" = "Window"; "native.wip" = "WIP"; +"native.withdrawn" = "Withdrawn"; "native.withoutItLitheAsksEveryTimeItConnectsARememberedSecretIsAnsweredOnlyTheFirstTimeTheServerA" = "Without it, Lithe asks every time it connects. A remembered secret is answered only the first time the server asks for it; if it is rejected, Lithe asks you."; "native.withoutRememberInKeychainLitheAsksEveryTimeItConnectsARememberedSecretIsAnsweredOnlyTheFir" = "Without Remember in Keychain, Lithe asks every time it connects. A remembered secret is answered only the first time the server asks for it; if it is rejected, Lithe asks you."; "native.workWithGithubRepositoriesIssuesPullRequestsAndActionsAPersonalAccessTokenIsRequired" = "Work with GitHub repositories, issues, pull requests and Actions. A personal access token is required."; diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index afee350c6..d19ad1ebb 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -331,6 +331,7 @@ "Ask questions about public GitHub repositories and read their generated documentation." = "向公开 GitHub 仓库提问,阅读自动生成的项目文档。"; "Ask the Agent about this workspace" = "向 Agent 询问这个工作区"; "Ask the Agent, Enter to send, Shift-Enter for a new line" = "向 Agent 提问,Enter 发送,Shift-Enter 换行"; +"Asked for access to %@" = "请求访问 %@"; "Assignees" = "负责人"; "Astro Support" = "Astro 支持"; "Attach" = "连接"; @@ -461,6 +462,7 @@ "ChangeList storage is unavailable. Restore the saved data and reopen the workspace." = "更改列表保存数据不可用。请恢复保存的数据后重新打开工作区。"; "Changed" = "已更改"; "Changed Files" = "已更改文件"; +"Changed the working directory to %@" = "已将工作目录切换到 %@"; "Changes" = "更改"; "Changes apply to the next turn." = "修改将在下一轮对话生效。"; "Changes are written to" = "更改写入"; @@ -469,6 +471,7 @@ "Changes requested" = "已请求修改"; "Changing channels requires a full installation and may install an older version. On its first launch, Lithe-Dev copies Stable settings and workspaces if it has no user data. The apps then save settings and caches separately. Some accounts may need sign-in again. Your project files stay in their current locations." = "切换渠道需要全量安装,可能安装较旧的版本。Lithe-Dev 首次启动且没有自己的用户数据时,会自动复制正式版的设置和工作区,之后两版分别保存设置和缓存。部分账号可能需要重新登录。项目文件仍保留在原位置。"; "Changing channels requires a full installation and may install an older version. Open the selected release page to download its installer. Your channel selection is saved for future updates." = "切换渠道需要全量安装,可能安装较旧的版本。请打开所选渠道的发布页下载安装包。渠道选择已保存,用于后续更新。"; +"Chapter: %@" = "章节:%@"; "Chat Completions" = "Chat Completions"; "Chat History" = "对话历史"; "Chat options" = "对话选项"; @@ -1445,6 +1448,7 @@ "HTTP is blocked until you explicitly allow it for this provider." = "在你为此服务商明确允许之前,HTTP 请求会被阻止。"; "HTTP is disabled for this provider. Enable the insecure HTTP option or use HTTPS." = "此服务商未允许 HTTP,请开启不安全 HTTP 选项或改用 HTTPS。"; "HTTP sends the API credential without encryption. Use only a trusted endpoint." = "HTTP 会以明文传输 API 凭据,请仅对可信地址启用。"; +"Handed off" = "已移交"; "Hard Reset (Discard Changes)" = "硬重置(丢弃更改)"; "Haskell Support" = "Haskell 支持"; "Head branch" = "来源分支"; @@ -1471,6 +1475,7 @@ "Hide plan" = "收起计划"; "Hide thinking" = "收起思考"; "Hide tool details" = "收起工具详情"; +"Hide visualization" = "隐藏可视化"; "Hide worktree repositories" = "隐藏工作树仓库"; "High" = "高"; "Highlight words" = "高亮单词"; @@ -1683,6 +1688,7 @@ "Line breakpoint actions" = "行断点操作"; "Linear" = "Linear"; "Lines kept for scrolling back in new terminal sessions." = "新建终端会话保留的可回滚行数。"; +"Linked this conversation to a pull request" = "已将此对话关联到拉取请求"; "Linked worktree" = "关联工作树"; "Listed in" = "所在插件仓库"; "Lithe" = "Lithe"; @@ -3026,6 +3032,7 @@ "Show thinking" = "展开思考"; "Show tool details" = "展开工具详情"; "Show usages and Git author" = "显示引用数量和 Git 作者"; +"Show visualization" = "显示可视化"; "Show worktree repositories" = "显示工作树仓库"; "Showing available matches." = "仍显示可用的匹配项。"; "Showing the previous Java entries while the Java language service prepares the project." = "Java 语言服务正在准备项目,暂时显示上次的 Java 入口。"; @@ -3123,11 +3130,13 @@ "Start debugging" = "开始调试"; "Start debugging the current Java file" = "开始调试当前 Java 文件"; "Start from" = "起始引用"; +"Start in new conversation" = "在新对话中开始"; "Start service" = "启动服务"; "Start the discussion or submit the first review." = "发起讨论或提交第一次审查。"; "Start the session by sending a message" = "发送消息以开始会话"; "Start this configuration to see its output here." = "启动此配置后,输出将显示在这里。"; "Start with an action verb, without a type prefix" = "以动作动词开头,不添加类型前缀"; +"Started in Claude Desktop" = "已在 Claude Desktop 中开始"; "Starting" = "正在启动"; "Starting Interactive Rebase" = "正在开始交互式变基"; "Starting Java language server..." = "正在启动 Java 语言服务器…"; @@ -3188,6 +3197,8 @@ "Submitting review…" = "正在提交审查…"; "Subscription accounts" = "订阅账号"; "Succeeded" = "成功"; +"Suggested" = "建议"; +"Suggested replies" = "建议的回复"; "Supplementary message cancelled." = "补充消息已取消。"; "Supported placeholders: {summary}, {changes}, {testing}, {risks}." = "支持的占位符:{summary}、{changes}、{testing}、{risks}。"; "Svelte Support" = "Svelte 支持"; @@ -3545,6 +3556,8 @@ "Turn on Model call statistics in Agent settings and reconnect to see the system prompt, tool definitions and messages." = "在 Agent 设置中开启“模型调用统计”并重新连接后,可查看系统提示词、工具定义和对话消息的占比。"; "Turn on Model call statistics, reconnect and send a message to see the breakdown." = "开启“模型调用统计”,重新连接并发送消息后可查看分项。"; "Turn usage" = "本轮用量"; +"Turned off auto-merge" = "已关闭自动合并"; +"Turned on auto-merge" = "已开启自动合并"; "Turning this off stops every Agent process." = "关闭后会停止所有 Agent 进程。"; "Turns" = "轮次"; "Type" = "类型"; @@ -3583,6 +3596,7 @@ "Unknown error" = "未知错误"; "Unknown source" = "未知来源"; "Unlink" = "取消关联"; +"Unlinked the pull request" = "已取消关联拉取请求"; "Unlock Worktree" = "解锁工作树"; "Unlock the worktree before removing it." = "请先解锁工作树再删除。"; "Unlocked" = "未锁定"; @@ -3729,6 +3743,7 @@ "View breakpoints" = "查看断点"; "View breakpoints (⌘⇧F8)" = "查看断点(⌘⇧F8)"; "View skill" = "查看"; +"Visualization" = "可视化"; "Vue Support" = "Vue 支持"; "WIP" = "进行中"; "WORKING TREE" = "工作区"; @@ -3756,6 +3771,8 @@ "Waiting to quit to complete the update." = "等待退出应用以完成更新。"; "Warning" = "警告"; "Warnings" = "警告"; +"Watching pull request checks" = "正在关注拉取请求检查"; +"Watching pull request checks and fixing failures" = "正在关注拉取请求检查并修复失败项"; "Welcome to Lithe" = "欢迎使用 Lithe"; "Welcome to Lithe Agent" = "欢迎使用 Lithe Agent"; "What Fetch removes or downloads before you start working." = "开始工作前,Fetch 会清理或下载什么。"; @@ -3774,6 +3791,7 @@ "Whitespace comparison" = "空白字符比较"; "Whole Words" = "全字匹配"; "Window" = "窗口"; +"Withdrawn" = "已撤回"; "Without Remember in Keychain, Lithe asks every time it connects. A remembered secret is answered only the first time the server asks for it; if it is rejected, Lithe asks you." = "不勾选“记住到钥匙串”时每次连接都会询问。记住的密码只在服务器第一次询问时自动填入;如果被拒绝,Lithe 会改为询问你。"; "Without it, Lithe asks every time it connects. A remembered secret is answered only the first time the server asks for it; if it is rejected, Lithe asks you." = "不勾选时每次连接都会询问。记住的密码只在服务器第一次询问时自动填入;如果被拒绝,Lithe 会改为询问你。"; "Work with GitHub repositories, issues, pull requests and Actions. A personal access token is required." = "访问 GitHub 仓库、Issue、PR 和 Actions。需要个人访问令牌。"; @@ -4106,6 +4124,7 @@ "native.askQuestionsAboutPublicGithubRepositoriesAndReadTheirGeneratedDocumentation" = "向公开 GitHub 仓库提问,阅读自动生成的项目文档。"; "native.askTheAgentAboutThisWorkspace" = "向 Agent 询问这个工作区"; "native.askTheAgentEnterToSendShiftEnterForANewLine" = "向 Agent 提问,Enter 发送,Shift-Enter 换行"; +"native.askedForAccessTo" = "请求访问 %1$@"; "native.assignees" = "负责人"; "native.astroSupport" = "Astro 支持"; "native.attach" = "连接"; @@ -4229,6 +4248,7 @@ "native.changed" = "已更改"; "native.changedFiles" = "已更改文件"; "native.changedOutsideLitheRefreshAndTryAgain" = "“%1$@”在 Lithe 之外被修改。请刷新后重试。"; +"native.changedTheWorkingDirectoryTo" = "已将工作目录切换到 %1$@"; "native.changelist" = "更改列表"; "native.changelistEditingIsUnavailable" = "暂时无法编辑更改列表。"; "native.changelistName" = "更改列表名称"; @@ -4241,6 +4261,7 @@ "native.changesRequested" = "已请求修改"; "native.changingChannelsRequiresAFullInstallationAndMayInstallAnOlderVersionOnItsFirste8ccfd1e52" = "切换渠道需要全量安装,可能安装较旧的版本。Lithe-Dev 首次启动且没有自己的用户数据时,会自动复制正式版的设置和工作区,之后两版分别保存设置和缓存。部分账号可能需要重新登录。项目文件仍保留在原位置。"; "native.changingChannelsRequiresAFullInstallationAndMayInstallAnOlderVersionOpenTheSelda3c463026" = "切换渠道需要全量安装,可能安装较旧的版本。请打开所选渠道的发布页下载安装包。渠道选择已保存,用于后续更新。"; +"native.chapter" = "章节:%1$@"; "native.charactersUsedByTheInterfaceAndYourFiles" = "界面和文件用到的字符"; "native.chars" = "字符"; "native.chatCompletions" = "Chat Completions"; @@ -5230,6 +5251,7 @@ "native.groovySupport" = "Groovy 支持"; "native.group" = "组"; "native.groups" = "组合"; +"native.handedOff" = "已移交"; "native.hardResetDiscardChanges" = "硬重置(丢弃更改)"; "native.haskellSupport" = "Haskell 支持"; "native.head" = "HEAD"; @@ -5260,6 +5282,7 @@ "native.hideTerminalToolWindow" = "隐藏终端工具窗口"; "native.hideThinking" = "收起思考"; "native.hideToolDetails" = "收起工具详情"; +"native.hideVisualization" = "隐藏可视化"; "native.hideWorktreeRepositories" = "隐藏工作树仓库"; "native.high" = "高"; "native.highlightWords" = "高亮单词"; @@ -5581,6 +5604,7 @@ "native.lineColumn" = "[行] [:列]:"; "native.linear" = "Linear"; "native.linesKeptForScrollingBackInNewTerminalSessions" = "新建终端会话保留的可回滚行数。"; +"native.linkedThisConversationToAPullRequest" = "已将此对话关联到拉取请求"; "native.linkedWorktree" = "关联工作树"; "native.listedIn" = "所在插件仓库"; "native.lithe" = "Lithe"; @@ -6975,6 +6999,7 @@ "native.showToolDetails" = "展开工具详情"; "native.showToolbar" = "显示工具栏"; "native.showUsagesAndGitAuthor" = "显示引用数量和 Git 作者"; +"native.showVisualization" = "显示可视化"; "native.showWorktreeRepositories" = "显示工作树仓库"; "native.showingAvailableMatches" = "仍显示可用的匹配项。"; "native.showingThePreviousJavaEntriesWhileTheJavaLanguageServicePreparesTheProject" = "Java 语言服务正在准备项目,暂时显示上次的 Java 入口。"; @@ -7076,6 +7101,7 @@ "native.startDebugging.variant2" = "开始调试"; "native.startDebuggingTheCurrentJavaFile" = "开始调试当前 Java 文件"; "native.startFrom" = "起始引用"; +"native.startInNewConversation" = "在新对话中开始"; "native.startRebase" = "开始变基"; "native.startRecording" = "开始录制"; "native.startService" = "启动服务"; @@ -7084,6 +7110,7 @@ "native.startThisConfigurationToSeeItsOutputHere" = "启动此配置后,输出将显示在这里。"; "native.startWithAnActionVerbWithoutATypePrefix" = "以动作动词开头,不添加类型前缀"; "native.startedATaskThatIsStillRunning" = "%1$@ 启动的任务仍在运行"; +"native.startedInClaudeDesktop" = "已在 Claude Desktop 中开始"; "native.starting" = "正在启动"; "native.startingCommitOptional" = "起始提交(可选)"; "native.startingInteractiveRebase" = "正在开始交互式变基"; @@ -7143,6 +7170,8 @@ "native.subjectMaximumLength" = "标题最大长度"; "native.submittingReview" = "正在提交审查…"; "native.succeeded" = "成功"; +"native.suggested" = "建议"; +"native.suggestedReplies" = "建议的回复"; "native.supplementaryMessageCancelled" = "补充消息已取消。"; "native.supportedPlaceholdersSummaryChangesTestingRisks" = "支持的占位符:{summary}、{changes}、{testing}、{risks}。"; "native.svelteSupport" = "Svelte 支持"; @@ -7506,6 +7535,8 @@ "native.turnOnModelCallStatisticsInAgentSettingsAndReconnectToSeeTheSystemPromptToolDeffd4968e05" = "在 Agent 设置中开启“模型调用统计”并重新连接后,可查看系统提示词、工具定义和对话消息的占比。"; "native.turnOnModelCallStatisticsReconnectAndSendAMessageToSeeTheBreakdown" = "开启“模型调用统计”,重新连接并发送消息后可查看分项。"; "native.turnUsage" = "本轮用量"; +"native.turnedOffAutoMerge" = "已关闭自动合并"; +"native.turnedOnAutoMerge" = "已开启自动合并"; "native.turningThisOffStopsEveryAgentProcess" = "关闭后会停止所有 Agent 进程。"; "native.turns" = "轮次"; "native.type" = "类型"; @@ -7542,6 +7573,7 @@ "native.unknownError" = "未知错误"; "native.unknownSource" = "未知来源"; "native.unlink" = "取消关联"; +"native.unlinkedThePullRequest" = "已取消关联拉取请求"; "native.unlockTheWorktreeBeforeRemovingIt" = "请先解锁工作树再删除。"; "native.unlockWorktree" = "解锁工作树"; "native.unlocked" = "未锁定"; @@ -7699,6 +7731,7 @@ "native.viewOptions" = "视图选项"; "native.viewSavedChanges" = "查看已保存的更改"; "native.viewSkill" = "查看"; +"native.visualization" = "可视化"; "native.vmArguments" = "VM 参数"; "native.vmOptions" = "VM 选项"; "native.vueSupport" = "Vue 支持"; @@ -7730,6 +7763,8 @@ "native.warnings" = "警告"; "native.wasCreatedOutsideLitheTakeOwnershipBeforeChangingIt" = "“%1$@”是在 Lithe 之外创建的。请先接管所有权再修改。"; "native.wasNotFoundInstallItForExampleThenCheckAgain" = "未找到 %1$@。请先安装(例如 `%2$@`),然后重新检测。"; +"native.watchingPullRequestChecks" = "正在关注拉取请求检查"; +"native.watchingPullRequestChecksAndFixingFailures" = "正在关注拉取请求检查并修复失败项"; "native.welcomeToLithe" = "欢迎使用 Lithe"; "native.welcomeToLitheAgent" = "欢迎使用 Lithe Agent"; "native.whatDoesThisPullRequestChange" = "这个拉取请求更改了什么?"; @@ -7749,6 +7784,7 @@ "native.wholeWords" = "全字匹配"; "native.window" = "窗口"; "native.wip" = "进行中"; +"native.withdrawn" = "已撤回"; "native.withoutItLitheAsksEveryTimeItConnectsARememberedSecretIsAnsweredOnlyTheFirstTimeTheServerA" = "不勾选时每次连接都会询问。记住的密码只在服务器第一次询问时自动填入;如果被拒绝,Lithe 会改为询问你。"; "native.withoutRememberInKeychainLitheAsksEveryTimeItConnectsARememberedSecretIsAnsweredOnlyTheFir" = "不勾选“记住到钥匙串”时每次连接都会询问。记住的密码只在服务器第一次询问时自动填入;如果被拒绝,Lithe 会改为询问你。"; "native.workWithGithubRepositoriesIssuesPullRequestsAndActionsAPersonalAccessTokenIsRequired" = "访问 GitHub 仓库、Issue、PR 和 Actions。需要个人访问令牌。"; diff --git a/macos/Sources/Lithe/Views/Agent/AgentDesktopCardView.swift b/macos/Sources/Lithe/Views/Agent/AgentDesktopCardView.swift new file mode 100644 index 000000000..cbe7126a6 --- /dev/null +++ b/macos/Sources/Lithe/Views/Agent/AgentDesktopCardView.swift @@ -0,0 +1,385 @@ +import AppKit +import SwiftUI +import WebKit +import LitheAgentConversationModule + +// Note: Claude Desktop 机制在 Agent 面板中的呈现见 .agents/notes/implemented/feature/2026-10-24-agent-transcript-images-and-task-notifications.md + +/// A Claude Desktop mechanism shown the way Desktop shows it: chapters as +/// dividers, suggested replies and tasks as buttons, pull requests and +/// visualizations as cards. Actions only fill the composer; nothing is sent +/// until the user sends it. +struct AgentDesktopCardView: View { + @Environment(\.locale) private var locale + @Environment(\.openURL) private var openURL + let card: AgentDesktopCard + /// Fills the composer, in a new conversation when the flag is set. + let onDraft: (String, Bool) -> Void + + var body: some View { + switch card { + case .chapter(let title, let summary): + chapter(title: title, summary: summary) + case .nextStep(let actions): + LitheChipFlowLayout(spacing: 6) { + ForEach(Array(actions.enumerated()), id: \.offset) { _, action in + Button { onDraft(action.message, false) } label: { + Text(verbatim: action.label) + .font(LitheTheme.uiFont(size: 12)) + .foregroundStyle(AgentPanelStyle.text) + .lineLimit(1) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background(AgentPanelStyle.context, in: Capsule()) + .overlay(Capsule().strokeBorder(AgentPanelStyle.border, lineWidth: 1)) + } + .buttonStyle(.litheNoPress) + .lithePointer() + .help(action.message) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityElement(children: .contain) + .accessibilityLabel(AgentLocalization.string("Suggested replies", locale: locale)) + .accessibilityIdentifier("agent-desktop-next-step") + case .task(let title, let summary, let prompt, let state): + task(title: title, summary: summary, prompt: prompt, state: state) + case .pullRequest(let action, let url, let number, let autoFix, let enabled): + line(systemImage: "arrow.triangle.pull", + text: pullRequestText(action, number: number, autoFix: autoFix, enabled: enabled)) { + Button { openURL(url) } label: { + Text(verbatim: number.map { "#\($0)" } ?? url.lastPathComponent) + .font(LitheTheme.uiFont(size: 11.5, weight: .medium)) + .foregroundStyle(LitheTheme.link) + } + .buttonStyle(.litheNoPress) + .lithePointer() + .help(url.absoluteString) + } + case .directory(let action, let path): + line(systemImage: "folder", + text: String(format: AgentLocalization.string(action == .changed ? "Changed the working directory to %@" : "Asked for access to %@", locale: locale), path)) { + EmptyView() + } + case .widget(let title, let code): + AgentDesktopWidgetCard(title: title, code: code, onPrompt: { onDraft($0, false) }) + } + } + + private func chapter(title: String, summary: String?) -> some View { + HStack(spacing: 8) { + Rectangle().fill(AgentPanelStyle.border).frame(height: 1) + Label { + Text(verbatim: title).lineLimit(1).truncationMode(.tail) + } icon: { + Image(systemName: "bookmark") + } + .font(LitheTheme.uiFont(size: 11.5, weight: .semibold)) + .foregroundStyle(AgentPanelStyle.secondary) + .layoutPriority(1) + Rectangle().fill(AgentPanelStyle.border).frame(height: 1) + } + .padding(.vertical, 2) + .help(summary ?? title) + .accessibilityElement(children: .ignore) + .accessibilityLabel(String(format: AgentLocalization.string("Chapter: %@", locale: locale), title)) + .accessibilityIdentifier("agent-desktop-chapter") + } + + private func task(title: String, summary: String?, prompt: String?, state: AgentDesktopCard.TaskState) -> some View { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 6) { + Image(systemName: "arrow.triangle.branch") + .font(LitheTheme.uiFont(size: 10.5)) + .foregroundStyle(state == .withdrawn ? AgentPanelStyle.muted : LitheTheme.accent) + Text(verbatim: title) + .font(LitheTheme.uiFont(size: 12, weight: .semibold)) + .foregroundStyle(state == .withdrawn ? AgentPanelStyle.muted : AgentPanelStyle.text) + .strikethrough(state == .withdrawn) + .lineLimit(2) + Spacer(minLength: 4) + Text(verbatim: taskStateText(state)) + .font(LitheTheme.uiFont(size: 10.5, weight: .medium)) + .foregroundStyle(AgentPanelStyle.secondary) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(AgentPanelStyle.context, in: Capsule()) + } + if let summary { + Text(verbatim: summary) + .font(LitheTheme.uiFont(size: 11.5)) + .foregroundStyle(AgentPanelStyle.secondary) + .lineLimit(4) + .textSelection(.enabled) + } + // Desktop runs the task in a session of its own; here it starts a new conversation. + if let prompt, state != .withdrawn { + Button { onDraft(prompt, true) } label: { + Label("Start in new conversation", systemImage: "plus.bubble") + .font(LitheTheme.uiFont(size: 11.5)) + } + .buttonStyle(LitheIconButtonStyle(size: 24, isIconOnly: false)) + .help(prompt) + .accessibilityIdentifier("agent-desktop-task-start") + } + } + .padding(.horizontal, 10) + .padding(.vertical, 8) + .frame(maxWidth: .infinity, alignment: .leading) + .background(AgentPanelStyle.header, in: RoundedRectangle(cornerRadius: 5)) + .overlay(RoundedRectangle(cornerRadius: 5).stroke(AgentPanelStyle.border, lineWidth: 1)) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("agent-desktop-task") + } + + private func line(systemImage: String, text: String, @ViewBuilder trailing: () -> Trailing) -> some View { + HStack(alignment: .firstTextBaseline, spacing: 6) { + Image(systemName: systemImage) + .font(LitheTheme.uiFont(size: 10.5)) + .foregroundStyle(AgentPanelStyle.secondary) + Text(verbatim: text) + .font(LitheTheme.uiFont(size: 11.5)) + .foregroundStyle(AgentPanelStyle.secondary) + .lineLimit(2) + .truncationMode(.middle) + .textSelection(.enabled) + trailing() + Spacer(minLength: 0) + } + .accessibilityElement(children: .combine) + } + + private func taskStateText(_ state: AgentDesktopCard.TaskState) -> String { + switch state { + case .proposed: AgentLocalization.string("Suggested", locale: locale) + case .withdrawn: AgentLocalization.string("Withdrawn", locale: locale) + case .started: AgentLocalization.string("Started in Claude Desktop", locale: locale) + case .handedOff: AgentLocalization.string("Handed off", locale: locale) + } + } + + private func pullRequestText(_ action: AgentDesktopCard.PullRequestAction, number: Int?, autoFix: Bool?, enabled: Bool?) -> String { + switch action { + case .bound: AgentLocalization.string("Linked this conversation to a pull request", locale: locale) + case .unbound: AgentLocalization.string("Unlinked the pull request", locale: locale) + case .monitored: autoFix == true + ? AgentLocalization.string("Watching pull request checks and fixing failures", locale: locale) + : AgentLocalization.string("Watching pull request checks", locale: locale) + case .autoMerge: enabled == false + ? AgentLocalization.string("Turned off auto-merge", locale: locale) + : AgentLocalization.string("Turned on auto-merge", locale: locale) + } + } +} + +/// A visualization the agent drew. The page is built only when the user opens it. +private struct AgentDesktopWidgetCard: View { + @Environment(\.locale) private var locale + @Environment(\.colorScheme) private var colorScheme + let title: String + let code: String + let onPrompt: (String) -> Void + @State private var expanded = false + @State private var height: CGFloat = 160 + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + Button { expanded.toggle() } label: { + HStack(spacing: 7) { + Image(systemName: "chart.xyaxis.line") + .font(LitheTheme.uiFont(size: 10.5)) + .foregroundStyle(LitheTheme.accent) + Text(verbatim: title.isEmpty ? AgentLocalization.string("Visualization", locale: locale) : title) + .font(LitheTheme.uiFont(size: 11.5, weight: .semibold)) + .foregroundStyle(AgentPanelStyle.text) + .lineLimit(1) + Spacer(minLength: 4) + LitheIDEAIcon(resourcePath: expanded ? "expui/general/chevronDown.svg" : "expui/general/chevronRight.svg", + size: LitheTheme.Metrics.toolbarIconSize, preservesOriginalColors: true) + } + .padding(.horizontal, 10) + .frame(height: 28) + .contentShape(Rectangle()) + } + .buttonStyle(LitheIconButtonStyle(size: 28, isIconOnly: false)) + .help(expanded ? "Hide visualization" : "Show visualization") + if expanded { + Rectangle().fill(AgentPanelStyle.border).frame(height: 1) + AgentDesktopWidgetWebView(code: code, isDark: colorScheme == .dark, height: $height, onPrompt: onPrompt) + .frame(height: height) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .background(AgentPanelStyle.header, in: RoundedRectangle(cornerRadius: 5)) + .clipShape(RoundedRectangle(cornerRadius: 5)) + .overlay(RoundedRectangle(cornerRadius: 5).stroke(AgentPanelStyle.border, lineWidth: 1)) + .accessibilityIdentifier("agent-desktop-widget") + } +} + +/// Renders agent-written HTML or SVG offline: the page cannot load anything, +/// keeps no storage, and cannot navigate or open links. Inline scripts run so interactive +/// explainers work; `sendPrompt` only fills the composer, and only right after +/// the user clicked or typed in the page, since the page's script is agent-written +/// and must not replace a draft or take focus on its own. +struct AgentDesktopWidgetWebView: NSViewRepresentable { + static let messageName = "litheWidgetPrompt" + static let heightRange: ClosedRange = 80...720 + /// How long after a click or key press in the page a `sendPrompt` still counts as the user's. + static let gestureWindow: TimeInterval = 2 + let code: String + let isDark: Bool + @Binding var height: CGFloat + let onPrompt: (String) -> Void + + /// Only the page this view loaded itself. A link never leaves the page, not even + /// to the system browser: a script can click its own link, and WebKit reports that + /// like a user's click, so opening it would let the page send its content to any address. + static func allowsNavigation(_ type: WKNavigationType, url: URL?) -> Bool { + type == .other && url?.absoluteString == "about:blank" + } + + /// A prompt is honoured only shortly after the user's own click or key press in the page. + static func isFollowingUserGesture(_ lastGesture: TimeInterval?, now: TimeInterval) -> Bool { + guard let lastGesture else { return false } + return (0...gestureWindow).contains(now - lastGesture) + } + + func makeCoordinator() -> Coordinator { Coordinator(self) } + + func makeNSView(context: Context) -> WKWebView { + let configuration = WKWebViewConfiguration() + configuration.websiteDataStore = .nonPersistent() + configuration.preferences.javaScriptCanOpenWindowsAutomatically = false + let prompt = WKUserScript( + source: "window.sendPrompt = (text) => window.webkit.messageHandlers.\(Self.messageName).postMessage(String(text));", + injectionTime: .atDocumentStart, forMainFrameOnly: true) + configuration.userContentController.addUserScript(prompt) + configuration.userContentController.add(WeakMessageHandler(context.coordinator), name: Self.messageName) + let webView = WKWebView(frame: .zero, configuration: configuration) + webView.navigationDelegate = context.coordinator + webView.setValue(false, forKey: "drawsBackground") + webView.loadHTMLString(Self.document(code: code, isDark: isDark), baseURL: nil) + context.coordinator.loadedDark = isDark + context.coordinator.trackUserGestures(in: webView) + return webView + } + + func updateNSView(_ webView: WKWebView, context: Context) { + context.coordinator.parent = self + if context.coordinator.loadedDark != isDark { + context.coordinator.loadedDark = isDark + webView.loadHTMLString(Self.document(code: code, isDark: isDark), baseURL: nil) + } + } + + static func dismantleNSView(_ webView: WKWebView, coordinator: Coordinator) { + webView.configuration.userContentController.removeAllScriptMessageHandlers() + webView.navigationDelegate = nil + coordinator.stopTrackingUserGestures() + } + + /// The agent's code inside a page whose policy forbids every network load, + /// with the theme variables Claude Desktop gives its widgets. + static func document(code: String, isDark: Bool) -> String { + """ + + + + + \(code) + """ + } + + private static func themeVariables(isDark: Bool) -> String { + func color(_ token: LitheTheme.ResolvedColorToken) -> String { + MonacoWorkbenchThemeConfiguration.cssColor(LitheTheme.nsColor(token, isDark: isDark)) + } + let text = color(.primaryText), secondary = color(.secondaryText), border = color(.divider) + let variables = [ + "--text-primary": text, "--text-secondary": secondary, + "--text-muted": "color-mix(in srgb, \(secondary) 75%, transparent)", + "--text-accent": color(.accent), "--text-danger": color(.error), + "--text-success": color(.success), "--text-warning": color(.warning), + "--surface-0": color(.editor), "--surface-1": color(.sidebar), "--surface-2": color(.toolHeader), + "--surface-3": color(.popupBackground), "--surface-panel": color(.sidebar), + "--surface-popover": color(.popupBackground), "--page-bg": "transparent", + "--border": border, "--border-strong": secondary, "--border-accent": color(.accent), + "--fill-primary": color(.accent), "--on-primary": "#ffffff", "--fill-accent": color(.accent), + "--bg-accent": "color-mix(in srgb, \(color(.accent)) 16%, transparent)", + "--bg-success": "color-mix(in srgb, \(color(.success)) 16%, transparent)", + "--bg-warning": "color-mix(in srgb, \(color(.warning)) 16%, transparent)", + "--bg-danger": "color-mix(in srgb, \(color(.error)) 16%, transparent)", + "--font-sans": "-apple-system, system-ui, sans-serif", + "--font-mono": "ui-monospace, SFMono-Regular, Menlo, monospace", + "--radius": "6px" + ] + return variables.sorted { $0.key < $1.key }.map { "\($0.key):\($0.value);" }.joined() + } + + final class Coordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler { + var parent: AgentDesktopWidgetWebView + var loadedDark = false + private var gestureMonitor: Any? + /// `NSEvent.timestamp` of the user's latest click or key press inside the page. + private var lastGesture: TimeInterval? + + init(_ parent: AgentDesktopWidgetWebView) { self.parent = parent } + + deinit { stopTrackingUserGestures() } + + /// WebKit gives the page no trustworthy user-gesture signal, so the host watches + /// the real input events delivered to this view. + func trackUserGestures(in webView: WKWebView) { + stopTrackingUserGestures() + gestureMonitor = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown, .keyDown]) { + [weak self, weak webView] event in + guard let webView, let window = webView.window, event.window === window else { return event } + let isInside = event.type == .keyDown + ? window.firstResponder.map { ($0 as? NSView)?.isDescendant(of: webView) == true } ?? false + : webView.bounds.contains(webView.convert(event.locationInWindow, from: nil)) + if isInside { self?.lastGesture = event.timestamp } + return event + } + } + + func stopTrackingUserGestures() { + if let gestureMonitor { NSEvent.removeMonitor(gestureMonitor) } + gestureMonitor = nil + } + + func webView(_ webView: WKWebView, decidePolicyFor action: WKNavigationAction, + decisionHandler: @escaping @MainActor @Sendable (WKNavigationActionPolicy) -> Void) { + decisionHandler(AgentDesktopWidgetWebView.allowsNavigation(action.navigationType, url: action.request.url) + ? .allow : .cancel) + } + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + webView.evaluateJavaScript("document.documentElement.scrollHeight") { [weak self] value, _ in + guard let self, let measured = value as? Double else { return } + let height = min(max(CGFloat(measured), AgentDesktopWidgetWebView.heightRange.lowerBound), + AgentDesktopWidgetWebView.heightRange.upperBound) + if abs(parent.height - height) > 1 { parent.height = height } + } + } + + func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage) { + guard message.frameInfo.isMainFrame, let text = message.body as? String, + !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + AgentDesktopWidgetWebView.isFollowingUserGesture(lastGesture, now: ProcessInfo.processInfo.systemUptime) + else { return } + parent.onPrompt(String(text.prefix(16_384))) + } + } + + /// The content controller retains its handlers; this keeps it from retaining the view. + private final class WeakMessageHandler: NSObject, WKScriptMessageHandler { + weak var target: WKScriptMessageHandler? + init(_ target: WKScriptMessageHandler) { self.target = target } + func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage) { + target?.userContentController(controller, didReceive: message) + } + } +} diff --git a/macos/Sources/Lithe/Views/Agent/AgentTranscriptView.swift b/macos/Sources/Lithe/Views/Agent/AgentTranscriptView.swift index bb8aa498f..598b85493 100644 --- a/macos/Sources/Lithe/Views/Agent/AgentTranscriptView.swift +++ b/macos/Sources/Lithe/Views/Agent/AgentTranscriptView.swift @@ -41,6 +41,7 @@ enum AgentTranscriptItem: Identifiable { case .message(let message): return message.text.localizedStandardContains(searchText) || message.plan?.entries.contains { $0.content.localizedStandardContains(searchText) } == true + || message.shownDesktopCard?.searchableText.localizedStandardContains(searchText) == true case .processGroup(let messages): return messages.contains { $0.role == .tool ? Self.toolMatches($0, searchText) : $0.text.localizedStandardContains(searchText) } @@ -49,6 +50,12 @@ enum AgentTranscriptItem: Identifiable { } } + /// Suggested replies answer only the latest turn; Desktop drops them once the conversation moves on. + func isStaleNextStep(lastMessageID: String?, isResponding: Bool) -> Bool { + guard case .message(let message) = self, case .nextStep = message.shownDesktopCard else { return false } + return isResponding || message.id != lastMessageID + } + static func toolMatches(_ message: AgentConversationMessage, _ searchText: String) -> Bool { message.text.localizedStandardContains(searchText) || message.toolDetails.input?.localizedStandardContains(searchText) == true @@ -87,9 +94,12 @@ enum AgentTranscriptItem: Identifiable { // Model-only reminders and caveats are not conversation. continue } - if message.plan != nil { + if case .nextStep = message.shownDesktopCard { + // Suggested replies follow the turn they end, after its summary. + } else if message.plan != nil || message.shownDesktopCard != nil { // A replayed plan snapshot is what the Agent reported at that point; // it stays on the transcript like Claude Desktop's checklist. + // Desktop shows its chapters, tasks and pull requests the same way. flush() items.append(.message(message)) } else if Self.isQuietNotification(message) { @@ -108,6 +118,10 @@ enum AgentTranscriptItem: Identifiable { flush() items.append(.turnSummary(summary, reply: reply)) } + if case .nextStep = message.shownDesktopCard { + flush() + items.append(.message(message)) + } } flush() return items @@ -278,7 +292,10 @@ struct AgentTranscriptView: View { guard let sessionID else { return } thoughtExpansions[sessionID, default: [:]][id] = value }, - onOpenFile: onOpenFile + onOpenFile: onOpenFile, + onDesktopDraft: { text, inNewConversation in + feature.fillDraft(text, inNewConversation: inNewConversation) + } ) .equatable() } @@ -396,6 +413,7 @@ private struct AgentTranscriptRows: View, Equatable { let thoughtExpansions: [String: AgentThoughtExpansion] let setThoughtExpansion: (String, AgentThoughtExpansion) -> Void let onOpenFile: (AgentToolDetails.Location) -> Void + let onDesktopDraft: (String, Bool) -> Void static func == (lhs: Self, rhs: Self) -> Bool { lhs.messages == rhs.messages && lhs.completedTurns == rhs.completedTurns @@ -406,7 +424,9 @@ private struct AgentTranscriptRows: View, Equatable { } var body: some View { - let matching = AgentTranscriptItem.grouped(messages, turns: completedTurns).filter { $0.matches(searchText) } + let matching = AgentTranscriptItem.grouped(messages, turns: completedTurns).filter { + $0.matches(searchText) && !$0.isStaleNextStep(lastMessageID: messages.last?.id, isResponding: liveProcessMessageID != nil) + } let hidden = limit < matching.count ? matching.count - limit : 0 let transcript = hidden > 0 ? Array(matching[hidden...]) : matching let turnEndingIDs = AgentTranscriptItem.repliesCopiedFromFooter(completedTurns, searchText: searchText) @@ -422,6 +442,8 @@ private struct AgentTranscriptRows: View, Equatable { switch item { case .message(let message) where message.plan != nil: AgentPlanView(plan: message.plan!, isResponding: false, horizontalInset: 0) + case .message(let message) where message.shownDesktopCard != nil: + AgentDesktopCardView(card: message.shownDesktopCard!, onDraft: onDesktopDraft) case .message(let message): AgentMessageRow( message: message, diff --git a/macos/Sources/Lithe/Views/App/PluginManagementView.swift b/macos/Sources/Lithe/Views/App/PluginManagementView.swift index f2f43a1a2..1b789631f 100644 --- a/macos/Sources/Lithe/Views/App/PluginManagementView.swift +++ b/macos/Sources/Lithe/Views/App/PluginManagementView.swift @@ -328,7 +328,7 @@ private struct InstalledPluginsView: View { private var filterChips: some View { // Wraps instead of scrolling so every filter stays visible in a // narrow list. - PluginFilterChipLayout(spacing: 5) { + LitheChipFlowLayout(spacing: 5) { ForEach(PluginListFilter.allCases, id: \.self) { value in let isSelected = filter == value Button { @@ -702,55 +702,6 @@ struct PluginManagementListContent { } } -/// Lays the filter chips out in rows, wrapping when the list is narrow. -private struct PluginFilterChipLayout: Layout { - let spacing: CGFloat - - func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { - let rows = rows(width: proposal.width ?? .infinity, subviews: subviews) - let width = rows.map(\.width).max() ?? 0 - let height = rows.map(\.height).reduce(0, +) + spacing * CGFloat(max(rows.count - 1, 0)) - return CGSize(width: proposal.width ?? width, height: height) - } - - func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { - var y = bounds.minY - for row in rows(width: bounds.width, subviews: subviews) { - var x = bounds.minX - for index in row.indices { - let size = subviews[index].sizeThatFits(.unspecified) - subviews[index].place(at: CGPoint(x: x, y: y), proposal: ProposedViewSize(size)) - x += size.width + spacing - } - y += row.height + spacing - } - } - - private struct Row { - var indices: [Int] = [] - var width: CGFloat = 0 - var height: CGFloat = 0 - } - - private func rows(width: CGFloat, subviews: Subviews) -> [Row] { - var rows: [Row] = [] - var current = Row() - for index in subviews.indices { - let size = subviews[index].sizeThatFits(.unspecified) - let proposed = current.indices.isEmpty ? size.width : current.width + spacing + size.width - if !current.indices.isEmpty, proposed > width { - rows.append(current) - current = Row() - } - current.width = current.indices.isEmpty ? size.width : current.width + spacing + size.width - current.height = max(current.height, size.height) - current.indices.append(index) - } - if !current.indices.isEmpty { rows.append(current) } - return rows - } -} - /// Installed plugin content; the shared tree owns focus, selection, hover and activation. private struct InstalledPluginRow: View { let displayName: String diff --git a/macos/Sources/Lithe/Views/Components/LitheChipFlowLayout.swift b/macos/Sources/Lithe/Views/Components/LitheChipFlowLayout.swift new file mode 100644 index 000000000..2c455198b --- /dev/null +++ b/macos/Sources/Lithe/Views/Components/LitheChipFlowLayout.swift @@ -0,0 +1,50 @@ +import SwiftUI + +/// Lays chips out in rows, wrapping when the space is narrow. +struct LitheChipFlowLayout: Layout { + let spacing: CGFloat + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + let rows = rows(width: proposal.width ?? .infinity, subviews: subviews) + let width = rows.map(\.width).max() ?? 0 + let height = rows.map(\.height).reduce(0, +) + spacing * CGFloat(max(rows.count - 1, 0)) + return CGSize(width: proposal.width ?? width, height: height) + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { + var y = bounds.minY + for row in rows(width: bounds.width, subviews: subviews) { + var x = bounds.minX + for index in row.indices { + let size = subviews[index].sizeThatFits(.unspecified) + subviews[index].place(at: CGPoint(x: x, y: y), proposal: ProposedViewSize(size)) + x += size.width + spacing + } + y += row.height + spacing + } + } + + private struct Row { + var indices: [Int] = [] + var width: CGFloat = 0 + var height: CGFloat = 0 + } + + private func rows(width: CGFloat, subviews: Subviews) -> [Row] { + var rows: [Row] = [] + var current = Row() + for index in subviews.indices { + let size = subviews[index].sizeThatFits(.unspecified) + let proposed = current.indices.isEmpty ? size.width : current.width + spacing + size.width + if !current.indices.isEmpty, proposed > width { + rows.append(current) + current = Row() + } + current.width = current.indices.isEmpty ? size.width : current.width + spacing + size.width + current.height = max(current.height, size.height) + current.indices.append(index) + } + if !current.indices.isEmpty { rows.append(current) } + return rows + } +} diff --git a/macos/Sources/LitheAgentConversationModule/Application/AgentConnectionModel.swift b/macos/Sources/LitheAgentConversationModule/Application/AgentConnectionModel.swift index 583b984e5..041dac8d6 100644 --- a/macos/Sources/LitheAgentConversationModule/Application/AgentConnectionModel.swift +++ b/macos/Sources/LitheAgentConversationModule/Application/AgentConnectionModel.swift @@ -497,6 +497,21 @@ public final class AgentConnectionModel: ObservableObject { composerFocusRequest &+= 1 } + /// Put text in the composer without sending it, as when the user picks a + /// reply or task Claude Desktop suggested. A new conversation keeps the current draft. + public func fillDraft(_ text: String, inNewConversation: Bool) { + if inNewConversation && selectedSessionID != nil { + if isCreatingSession || !unassignedDraft.isEmpty { + saveDraft() + restoreDraft(unassignedDraft) + selectedSessionID = nil + prepareConversation() + } else { startNewConversation() } + } + draftText = text + composerFocusRequest &+= 1 + } + private func saveDraft() { let draft = Draft(text: draftText, files: draftFiles) if let selectedSessionID { sessionDrafts[selectedSessionID] = draft } diff --git a/macos/Sources/LitheAgentConversationModule/Application/AgentDesktopCard.swift b/macos/Sources/LitheAgentConversationModule/Application/AgentDesktopCard.swift new file mode 100644 index 000000000..c04a246ce --- /dev/null +++ b/macos/Sources/LitheAgentConversationModule/Application/AgentDesktopCard.swift @@ -0,0 +1,96 @@ +import Foundation + +/// A Claude Desktop mechanism the agent host recognised in a tool call and +/// described in `_meta.lithe.claudeDesktop`, so the panel can show it as +/// Desktop does rather than as a raw tool row. Clients that ignore the card +/// still see the readable title the host gave the row. +public enum AgentDesktopCard: Equatable, Sendable { + public struct Action: Equatable, Sendable { + public var label: String + public var message: String + } + + public enum TaskState: String, Sendable { + case proposed, withdrawn, started, handedOff + } + + public enum PullRequestAction: String, Sendable { + case bound, unbound, monitored, autoMerge + } + + public enum DirectoryAction: String, Sendable { + case changed, requested + } + + /// A chapter marker the agent set to divide a long conversation. + case chapter(title: String, summary: String?) + /// Replies Desktop offers as buttons after the agent wraps up a turn. + case nextStep(actions: [Action]) + /// Work the agent proposed for a separate session, or handed off to one. + case task(title: String, summary: String?, prompt: String?, state: TaskState) + case pullRequest(action: PullRequestAction, url: URL, number: Int?, autoFix: Bool?, enabled: Bool?) + case directory(action: DirectoryAction, path: String) + /// HTML or SVG the agent drew with Desktop's visualizer. + case widget(title: String, code: String) + + static let widgetLimit = 262_144 + + init?(_ value: Any?) { + guard let card = value as? [String: Any], let type = card["card"] as? String else { return nil } + func text(_ key: String) -> String? { + (card[key] as? String).map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }.flatMap { $0.isEmpty ? nil : $0 } + } + switch type { + case "chapter": + guard let title = text("title") else { return nil } + self = .chapter(title: title, summary: text("summary")) + case "nextStep": + let actions = (card["actions"] as? [[String: Any]] ?? []).prefix(4).compactMap { action -> Action? in + guard let label = (action["label"] as? String).flatMap({ $0.isEmpty ? nil : $0 }), + let message = (action["message"] as? String).flatMap({ $0.isEmpty ? nil : $0 }) else { return nil } + return Action(label: label, message: message) + } + guard !actions.isEmpty else { return nil } + self = .nextStep(actions: actions) + case "task": + guard let title = text("title") else { return nil } + self = .task(title: title, summary: text("summary"), prompt: text("prompt"), + state: text("state").flatMap(TaskState.init(rawValue:)) ?? .proposed) + case "pullRequest": + // Only web links: the URL becomes a clickable button. + guard let action = text("action").flatMap(PullRequestAction.init(rawValue:)), + let url = text("url").flatMap(URL.init(string:)), + ["http", "https"].contains(url.scheme?.lowercased() ?? "") else { return nil } + self = .pullRequest(action: action, url: url, number: card["number"] as? Int, + autoFix: card["autoFix"] as? Bool, enabled: card["enabled"] as? Bool) + case "directory": + guard let action = text("action").flatMap(DirectoryAction.init(rawValue:)), let path = text("path") else { return nil } + self = .directory(action: action, path: path) + case "widget": + guard let code = card["code"] as? String, !code.isEmpty, code.count <= Self.widgetLimit else { return nil } + self = .widget(title: text("title") ?? "", code: code) + default: + return nil + } + } + + /// Words the transcript search should find this card by. + public var searchableText: String { + switch self { + case .chapter(let title, let summary): [title, summary].compactMap { $0 }.joined(separator: "\n") + case .nextStep(let actions): actions.map(\.label).joined(separator: "\n") + case .task(let title, let summary, let prompt, _): [title, summary, prompt].compactMap { $0 }.joined(separator: "\n") + case .pullRequest(_, let url, _, _, _): url.absoluteString + case .directory(_, let path): path + case .widget(let title, _): title + } + } +} + +public extension AgentConversationMessage { + /// The Desktop card this tool call shows on the transcript. A call that + /// failed or was denied never happened in Desktop either, so it stays a tool row. + var shownDesktopCard: AgentDesktopCard? { + role == .tool && toolStatus == .completed ? toolDetails.desktopCard : nil + } +} diff --git a/macos/Sources/LitheAgentConversationModule/Application/AgentImage.swift b/macos/Sources/LitheAgentConversationModule/Application/AgentImage.swift index e0c915981..95e7bb215 100644 --- a/macos/Sources/LitheAgentConversationModule/Application/AgentImage.swift +++ b/macos/Sources/LitheAgentConversationModule/Application/AgentImage.swift @@ -242,7 +242,11 @@ public enum AgentClaudeCodeMarkup { /// Wrappers that exist only for the model; they produce no entry. private static let hiddenTags = ["local-command-caveat", "system-reminder", "user-prompt-submit-hook"] /// Wrappers whose whole body is shown collapsed, with the tag as its name. - private static let contextTags: Set = ["lithe_workspace_context", "side-sessions-event", "create-pr-command"] + private static let contextTags: Set = [ + "lithe_workspace_context", "side-sessions-event", "create-pr-command", "ci-monitor-event", + ] + /// Claude Desktop's harness notices about other sessions, written as `[Label] text`. + private static let desktopNotices = ["Cross-session idle notice", "Cross-session delivery notice", "handback-send-enforce"] private static let knownTags: Set = contextTags.union([ "task-notification", "command-name", "command-message", "command-args", "local-command-stdout", "local-command-stderr", "bash-input", "bash-stdout", "bash-stderr", @@ -284,6 +288,10 @@ public enum AgentClaudeCodeMarkup { return [.interrupted(String(trimmed.dropFirst().dropLast()))] } if trimmed.hasPrefix(summaryPrefix) { return [summary(trimmed)] } + if let notice = desktopNotices.first(where: { trimmed.hasPrefix("[\($0)]") }) { + let body = trimmed.dropFirst(notice.count + 2).trimmingCharacters(in: .whitespacesAndNewlines) + return [.context(name: notice, body: body.isEmpty ? trimmed : body)] + } if trimmed.hasPrefix(skillPrefix) { let first = trimmed.split(separator: "\n", maxSplits: 1).first.map(String.init) ?? trimmed let directory = first.dropFirst(skillPrefix.count).trimmingCharacters(in: .whitespaces) @@ -300,8 +308,10 @@ public enum AgentClaudeCodeMarkup { } while !rest.isEmpty { guard rest.hasPrefix("<"), let close = rest.firstIndex(of: ">") else { return nil } - let tag = String(rest[rest.index(after: rest.startIndex)..") else { return nil } + // Desktop's event wrappers can carry attributes, such as the pull request a CI event is about. + let opening = rest[rest.index(after: rest.startIndex)..") else { return nil } let body = String(rest[rest.index(after: close).. AgentConversationMessage { + var message = AgentConversationMessage(id: id, role: .tool, text: title, toolStatus: status) + message.toolDetails.merge(["kind": "other", "_meta": ["lithe": ["claudeDesktop": card]]]) + return message + } + + @Test + func cardsParseFromHostMetadataAndRejectWhatTheyCannotShow() { + #expect(AgentDesktopCard(["card": "chapter", "title": " Setup ", "summary": ""]) == .chapter(title: "Setup", summary: nil)) + #expect(AgentDesktopCard(["card": "nextStep", "actions": [["label": "Ship", "message": "Open the PR"], ["label": "", "message": "x"]]]) + == .nextStep(actions: [.init(label: "Ship", message: "Open the PR")])) + #expect(AgentDesktopCard(["card": "nextStep", "actions": []]) == nil) + #expect(AgentDesktopCard(["card": "task", "title": "Fix CI", "prompt": "Fix it", "state": "handedOff"]) + == .task(title: "Fix CI", summary: nil, prompt: "Fix it", state: .handedOff)) + #expect(AgentDesktopCard(["card": "task", "title": "Fix CI", "state": "unknown"]) + == .task(title: "Fix CI", summary: nil, prompt: nil, state: .proposed)) + // A pull request link is a button, so only web links are accepted. + #expect(AgentDesktopCard(["card": "pullRequest", "action": "bound", "url": "file:///etc/passwd"]) == nil) + #expect(AgentDesktopCard(["card": "pullRequest", "action": "monitored", "url": "https://github.com/o/r/pull/7", "number": 7, "autoFix": true]) + == .pullRequest(action: .monitored, url: URL(string: "https://github.com/o/r/pull/7")!, number: 7, autoFix: true, enabled: nil)) + #expect(AgentDesktopCard(["card": "directory", "action": "changed", "path": "/tmp/w"]) == .directory(action: .changed, path: "/tmp/w")) + #expect(AgentDesktopCard(["card": "widget", "title": "Flow", "code": NSNull()]) == nil) + #expect(AgentDesktopCard(["card": "future"]) == nil) + } + + @Test + func aLaterUpdateRestatesTheCardWithoutAnUpdateWithoutOneClearingIt() { + var details = AgentToolDetails() + details.merge(["_meta": ["lithe": ["claudeDesktop": ["card": "task", "title": "Fix CI", "state": "proposed"]]]]) + details.merge(["status": "completed", "rawOutput": "task_7 proposed"]) + #expect(details.desktopCard == .task(title: "Fix CI", summary: nil, prompt: nil, state: .proposed)) + details.merge(["_meta": ["lithe": ["claudeDesktop": ["card": "task", "title": "Fix CI", "state": "started"]]]]) + #expect(details.desktopCard == .task(title: "Fix CI", summary: nil, prompt: nil, state: .started)) + } + + @Test + func completedCardsStandAloneWhileDeniedOnesStayInTheProcess() { + let messages = [ + AgentConversationMessage(id: "user", role: .user, text: "Go"), + AgentConversationMessage(id: "read", role: .tool, text: "Read file", toolStatus: .completed), + tool("chapter", "Chapter: Setup", card: ["card": "chapter", "title": "Setup"]), + tool("denied", "Chapter: Denied", status: .failed, card: ["card": "chapter", "title": "Denied"]), + AgentConversationMessage(id: "reply", role: .agent, text: "Done"), + ] + let items = AgentTranscriptItem.grouped(messages) + #expect(items.map(\.id) == ["user", "process:read", "chapter", "process:denied", "reply"]) + #expect(items.filter { $0.matches("Setup") }.map(\.id) == ["chapter"]) + } + + @Test + func suggestedRepliesFollowTheTurnSummaryAndOnlyAnswerTheLatestTurn() { + let start = ContinuousClock.Instant.now + var turn = AgentTurnStatistics(id: "user", startedAt: start) + turn.finish(at: start.advanced(by: .seconds(3)), endingMessageID: "wrap#next", usage: nil) + let messages = [ + AgentConversationMessage(id: "user", role: .user, text: "Go"), + AgentConversationMessage(id: "read", role: .tool, text: "Read file", toolStatus: .completed), + AgentConversationMessage(id: "recap", role: .agent, text: "Finished. Open a PR?"), + tool("wrap#next", "Suggested replies", card: ["card": "nextStep", "actions": [["label": "Yes", "message": "Open the PR"]]]), + ] + let items = AgentTranscriptItem.grouped(messages, turns: [turn]) + #expect(items.map(\.id) == ["user", "process:read", "recap", "turn:user", "wrap#next"]) + guard case .turnSummary(_, let reply) = items[3] else { + Issue.record("Expected the turn summary before the suggested replies") + return + } + #expect(reply == "Finished. Open a PR?") + let replies = items[4] + #expect(!replies.isStaleNextStep(lastMessageID: "wrap#next", isResponding: false)) + #expect(replies.isStaleNextStep(lastMessageID: "wrap#next", isResponding: true)) + #expect(replies.isStaleNextStep(lastMessageID: "later", isResponding: false)) + } + + @Test + func desktopEventsAndHarnessNoticesAreContextNotUserWords() { + #expect(AgentClaudeCodeMarkup.parse("Checks failed") + == [.context(name: "ci-monitor-event", body: "Checks failed")]) + #expect(AgentClaudeCodeMarkup.parse("[Cross-session idle notice] \"Docs\" is idle now.") + == [.context(name: "Cross-session idle notice", body: "\"Docs\" is idle now.")]) + // Attributes are accepted only on Desktop's event wrappers. + #expect(AgentClaudeCodeMarkup.parse("ls") == nil) + #expect(AgentClaudeCodeMarkup.parse("[Cross-session note] mine") == nil) + } + + @Test + func widgetPageForbidsNetworkLoadsAndCarriesDesktopThemeVariables() { + let page = AgentDesktopWidgetWebView.document(code: "", isDark: true) + let policy = page.range(of: "default-src 'none'") + let code = page.range(of: "") + #expect(policy != nil && code != nil && policy!.lowerBound < code!.lowerBound, "The policy must precede the agent's markup") + #expect(page.contains("--text-primary:") && page.contains("--surface-1:") && page.contains("color-scheme:dark")) + #expect(!page.contains("connect-src")) + } + + @Test + func cardsFitANarrowPanelInBothThemes() throws { + let cards: [AgentDesktopCard] = [ + .chapter(title: "Investigate the release pipeline and its signing step", summary: "Long"), + .nextStep(actions: [.init(label: "Open the PR", message: "Open"), .init(label: "Run the full test suite", message: "Run"), + .init(label: "Explain", message: "Explain")]), + .task(title: "Fix the flaky Windows job", summary: "The job times out on cold caches.", prompt: "Fix it", state: .proposed), + .pullRequest(action: .monitored, url: URL(string: "https://github.com/o/r/pull/1421")!, number: 1421, autoFix: true, enabled: nil), + .directory(action: .changed, path: "/Users/someone/Dev/a/very/long/path/to/a/worktree"), + .widget(title: "Architecture", code: ""), + ] + for scheme in [ColorScheme.dark, .light] { + let width = 300.0 + let host = NSHostingView(rootView: VStack(spacing: 14) { + ForEach(Array(cards.enumerated()), id: \.offset) { _, card in AgentDesktopCardView(card: card, onDraft: { _, _ in }) } + }.padding(8).frame(width: width).background(AgentPanelStyle.canvas).environment(\.colorScheme, scheme)) + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: width, height: 600), + styleMask: [.borderless], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + defer { window.close() } + // Theme colours follow the window's appearance, not only the SwiftUI scheme. + window.appearance = NSAppearance(named: scheme == .dark ? .darkAqua : .aqua) + window.contentView = host + host.frame.size = NSSize(width: width, height: 600) + host.layoutSubtreeIfNeeded() + #expect(host.fittingSize.width <= width) + #expect(host.fittingSize.height <= 600, "Cards must wrap within a narrow panel") + if let folder = ProcessInfo.processInfo.environment["LITHE_AGENT_STATISTICS_SCREENSHOTS"] { + let bitmap = try #require(host.bitmapImageRepForCachingDisplay(in: host.bounds)) + host.cacheDisplay(in: host.bounds, to: bitmap) + let data = try #require(bitmap.representation(using: .png, properties: [:])) + try data.write(to: URL(fileURLWithPath: folder).appendingPathComponent("desktop-cards-\(scheme == .dark ? "dark" : "light").png")) + } + } + } + + @Test + func widgetPromptsCountOnlyRightAfterAUserGesture() { + typealias Widget = AgentDesktopWidgetWebView + #expect(!Widget.isFollowingUserGesture(nil, now: 100), "A page that runs on its own never fills the composer") + #expect(Widget.isFollowingUserGesture(99.5, now: 100)) + #expect(Widget.isFollowingUserGesture(100, now: 100)) + #expect(!Widget.isFollowingUserGesture(100 - Widget.gestureWindow - 0.1, now: 100), "A stale gesture is not the user's") + #expect(!Widget.isFollowingUserGesture(101, now: 100), "A gesture cannot come from the future") + } + + @Test + func widgetPagesCannotNavigateOrOpenLinksByThemselves() throws { + typealias Widget = AgentDesktopWidgetWebView + #expect(Widget.allowsNavigation(.other, url: URL(string: "about:blank"))) + // A script's own `a.click()` is reported as a link activation, like a user's. + #expect(!Widget.allowsNavigation(.linkActivated, url: URL(string: "https://example.invalid/?d=secret"))) + #expect(!Widget.allowsNavigation(.formSubmitted, url: URL(string: "https://example.invalid/"))) + #expect(!Widget.allowsNavigation(.other, url: URL(string: "https://example.invalid/"))) + #expect(!Widget.allowsNavigation(.other, url: nil)) + } +} diff --git a/rust/lithe-agent-host/src/claude_desktop.rs b/rust/lithe-agent-host/src/claude_desktop.rs index 3999820f2..6b8cd1a75 100644 --- a/rust/lithe-agent-host/src/claude_desktop.rs +++ b/rust/lithe-agent-host/src/claude_desktop.rs @@ -1,13 +1,24 @@ //! Readable replay of sessions started in Claude Desktop. //! -//! Claude Desktop runs Claude Code with its own `ccd_turn` MCP server. The -//! model there rarely writes prose: it reports progress through `update_plan` -//! and ends a turn with a `wrap_up` recap, which Desktop renders as a checklist -//! and a card. Resumed in Lithe, both would be anonymous rows in the collapsed -//! process area and the transcript would look empty. This presenter turns them -//! into what Desktop shows: an ACP `plan` update and an agent message. It -//! applies to the adapter's replay and to the earlier history read from disk -//! alike; Lithe never offers the `ccd_turn` tools, so live turns are unaffected. +//! Claude Desktop runs Claude Code with its own MCP servers (`ccd_turn`, +//! `ccd_session`, `ccd_pr`, `ccd_view`, `visualize`, `terminal`, …). The model +//! there reports progress, ends turns, proposes follow-up tasks and binds pull +//! requests through those tools, and Desktop draws each as part of its UI. +//! Resumed in Lithe, they would be anonymous `mcp__…` rows in the collapsed +//! process area and the transcript would look empty or cryptic. +//! +//! This presenter turns them into what Desktop shows, in two ways: +//! +//! - Standard ACP where it has a shape: `wrap_up` becomes the turn's agent +//! message, `update_plan` a `plan` update, files a pane showed become tool +//! locations. +//! - A card for mechanisms ACP has no shape for, as `_meta.lithe.claudeDesktop` +//! on the tool call (see `shared/contracts/application-boundary.md`). Every +//! such call keeps a readable English title and kind, so a client that does +//! not draw the card still shows a sensible tool row. +//! +//! It applies to the adapter's replay and to the earlier history read from disk +//! alike. Lithe never offers these tools, so live turns are unaffected. use std::collections::{HashMap, HashSet}; @@ -17,6 +28,10 @@ use serde_json::{json, Value}; const WRAP_UP_TOOL: &str = "mcp__ccd_turn__wrap_up"; /// Tool Claude Desktop's model uses to set or advance its step checklist. const UPDATE_PLAN_TOOL: &str = "mcp__ccd_turn__update_plan"; +/// Widget source above this many characters is not carried in the card. +const WIDGET_CODE_LIMIT: usize = 256 * 1024; +/// Limit for prompts, summaries and other free text carried in a card. +const CARD_TEXT_LIMIT: usize = 16 * 1024; /// One checklist step; `status` is an ACP plan status /// (`pending`, `in_progress` or `completed`). @@ -34,52 +49,478 @@ pub(crate) struct Presenter { /// Latest checklist per session. `update_plan` may only advance or tick /// steps of the list set earlier, so each call is applied to this state. plans: HashMap>, + /// Readable title and kind per retitled call, kept so a later update that + /// restates the adapter's own title does not undo them. + titles: HashMap, + /// Task cards by tool call id, kept so a later call can change their state. + tasks: HashMap, + /// Desktop task id (`task_…`) → tool call id of the card that proposed it. + task_ids: HashMap, + /// Calls that change a task card once their result arrives, by tool call id. + task_changes: HashMap, +} + +/// A `dismiss_task` or `start_session` call held back until its result says +/// whether the card it targets really changed. +struct TaskChange { + task: String, + /// State a successful result means. + state: &'static str, + /// The call as a readable tool row, shown only if the call fails. + call: Value, } impl Presenter { - /// `update` as the client should see it, or None when it is absorbed. - pub(crate) fn present(&mut self, session_id: &str, update: Value) -> Option { + /// `update` as the client should see it: usually one update, none when it + /// is absorbed, two when a turn's recap also offers suggested replies. + pub(crate) fn present(&mut self, session_id: &str, update: Value) -> Vec { + if update["sessionUpdate"] == "agent_message_chunk" { + return vec![present_pr_created(update)]; + } let Some(id) = update["toolCallId"].as_str().map(str::to_owned) else { - return Some(update); + return vec![update]; }; - let shown = match update["sessionUpdate"].as_str() { - Some("tool_call") if update["name"] == WRAP_UP_TOOL => { - wrap_up_text(&update["rawInput"]).map(|text| { - json!({ - "sessionUpdate": "agent_message_chunk", - "content": {"type": "text", "text": text}, - }) - }) + match update["sessionUpdate"].as_str() { + Some("tool_call") => self.tool_call(session_id, id, update), + Some("tool_call_update") => self.tool_call_update(id, update), + _ => vec![update], + } + } + + fn tool_call(&mut self, session_id: &str, id: String, update: Value) -> Vec { + let Some(name) = update["name"].as_str().map(str::to_owned) else { + return vec![update]; + }; + let input = update["rawInput"].clone(); + match name.as_str() { + WRAP_UP_TOOL => { + let Some(text) = wrap_up_text(&input) else { + return vec![update]; + }; + self.absorbed.insert(id.clone()); + let mut shown = vec![json!({ + "sessionUpdate": "agent_message_chunk", + "content": {"type": "text", "text": text}, + })]; + // The suggested replies are buttons in Desktop's next-step box. + if let Some(actions) = next_step_actions(&input) { + let mut replies = json!({ + "sessionUpdate": "tool_call", + "toolCallId": format!("{id}#next"), + "title": "Suggested replies", + "kind": "other", + "status": "completed", + }); + set_card(&mut replies, json!({"card": "nextStep", "actions": actions})); + shown.push(replies); + } + shown } - Some("tool_call") if update["name"] == UPDATE_PLAN_TOOL => { + UPDATE_PLAN_TOOL => { let steps = self.plans.entry(session_id.to_owned()).or_default(); - apply_plan(steps, &update["rawInput"]).then(|| plan_update(steps)) + if apply_plan(steps, &input) { + self.absorbed.insert(id); + return vec![plan_update(steps)]; + } + self.retitle(id, update, "Updated the task list", "think") } - Some("tool_call_update") if self.absorbed.contains(&id) => return None, - _ => None, - }; - match shown { - Some(shown) => { + "mcp__ccd_session__dismiss_task" => { + // Shown as the state of the card it withdraws, once the result confirms it. + if let Some(task) = str_field(&input, "task_id") { + let call = readable_call(update, "Withdrew a suggested task"); + self.task_changes.insert(id.clone(), TaskChange { task: task.to_owned(), state: "withdrawn", call }); + } + self.absorbed.insert(id); + Vec::new() + } + "mcp__ccd_session__start_session" if str_field(&input, "proposal_id").is_some() => { + // Starting a proposed card: the card itself changes state. + let task = str_field(&input, "proposal_id").unwrap_or_default().to_owned(); + let call = readable_call(update, "Started a suggested task"); + self.task_changes.insert(id.clone(), TaskChange { task, state: "started", call }); self.absorbed.insert(id); - Some(shown) + Vec::new() } - None => Some(update), + _ => { + let Some(described) = describe(&name, &input) else { + return vec![update]; + }; + let mut update = update; + if !described.locations.is_empty() { + update["locations"] = Value::Array(described.locations); + } + if let Some(card) = described.card { + if card["card"] == "task" { + self.tasks.insert(id.clone(), card.clone()); + } + set_card(&mut update, card); + } + self.retitle(id, update, &described.title, described.kind) + } + } + } + + fn tool_call_update(&mut self, id: String, mut update: Value) -> Vec { + if self.task_changes.contains_key(&id) { + return self.finish_task_change(&id, update); } + if self.absorbed.contains(&id) { + return Vec::new(); + } + if self.tasks.contains_key(&id) { + if let Some(task) = task_id_in(&result_text(&update)) { + self.task_ids.insert(task, id.clone()); + } + } + if let Some((title, kind)) = self.titles.get(&id) { + if update.get("title").is_some() { + update["title"] = json!(title); + update["kind"] = json!(kind); + } + } + vec![update] + } + + /// A held-back task call got an update: wait out progress, apply a success + /// to its card, and keep a failure visible, since nothing changed. + fn finish_task_change(&mut self, id: &str, update: Value) -> Vec { + let status = update["status"].as_str(); + if !matches!(status, Some("completed" | "failed")) { + return Vec::new(); + } + let Some(change) = self.task_changes.remove(id) else { + return vec![update]; + }; + self.absorbed.remove(id); + if status == Some("failed") { + return vec![change.call, update]; + } + // dismiss_task reports a task the user already started instead of withdrawing it. + let text = result_text(&update); + let state = if text.contains("already started") || text.contains("no longer pending") { + "started" + } else { + change.state + }; + self.change_task(&change.task, state) + } + + fn retitle(&mut self, id: String, mut update: Value, title: &str, kind: &'static str) -> Vec { + update["title"] = json!(title); + update["kind"] = json!(kind); + self.titles.insert(id, (title.to_owned(), kind)); + vec![update] + } + + /// The card that proposed `task`, now in `state`. + fn change_task(&mut self, task: &str, state: &'static str) -> Vec { + let Some(call) = self.task_ids.get(task).cloned() else { + return Vec::new(); + }; + let Some(card) = self.tasks.get_mut(&call) else { + return Vec::new(); + }; + card["state"] = json!(state); + let mut update = json!({"sessionUpdate": "tool_call_update", "toolCallId": call}); + set_card(&mut update, card.clone()); + vec![update] } } -/// The recap followed by the question, as separate paragraphs. The suggested -/// replies are buttons in Desktop; the prompt the user chose follows in the -/// transcript, so they are not repeated here. +/// `call` as an ordinary tool row with a readable title. +fn readable_call(mut call: Value, title: &str) -> Value { + call["title"] = json!(title); + call["kind"] = json!("other"); + call +} + +/// Whether a tool's input feeds a card, plan or reply, so a replay must carry +/// it whole instead of cut for a tool-detail preview. +pub(crate) fn carries_card_input(name: &str) -> bool { + name.strip_prefix("mcp__") + .and_then(|rest| rest.split_once("__")) + .is_some_and(|(server, _)| { + matches!( + server, + "ccd_turn" | "ccd_session" | "ccd_pr" | "ccd_directory" | "ccd_view" | "ccd_host" | "visualize" + ) + }) +} + +/// Largest string of such an input a replay carries; the widget source limit +/// is the largest of the card fields. +pub(crate) const CARD_INPUT_LIMIT: usize = WIDGET_CODE_LIMIT; + +/// Readable presentation of one Desktop tool call. +struct Described { + title: String, + kind: &'static str, + locations: Vec, + card: Option, +} + +impl Described { + fn row(title: impl Into, kind: &'static str) -> Self { + Self { title: title.into(), kind, locations: Vec::new(), card: None } + } + + fn card(title: impl Into, card: Value) -> Self { + Self { card: Some(card), ..Self::row(title, "other") } + } +} + +/// Title, kind and card for a Desktop tool, or None for any other tool. +fn describe(name: &str, input: &Value) -> Option { + let (server, tool) = name.strip_prefix("mcp__")?.split_once("__")?; + let field = |key: &str| str_field(input, key); + let described = match (server, tool) { + ("ccd_session", "mark_chapter") => { + let title = field("title")?; + Described::card( + format!("Chapter: {title}"), + json!({"card": "chapter", "title": title, "summary": field("summary")}), + ) + } + ("ccd_session", "spawn_task") => { + task_card(field("title")?, field("tldr"), field("prompt"), "proposed") + } + ("ccd_session", "start_session") => task_card( + field("title")?, + None, + field("prompt"), + // The model may only propose; a start the user asked for runs at once. + if field("initiation") == Some("own_initiative") { "proposed" } else { "started" }, + ), + ("ccd_session", "hand_off_to_session") => { + let plan = field("remaining_plan"); + let summary = plan.and_then(|plan| plan.lines().map(str::trim).find(|line| !line.is_empty())); + task_card(field("title")?, summary, plan, "handedOff") + } + ("ccd_session", "read_widget_context") => Described::row("Read the widget's context", "read"), + ("ccd_pr", "get_status") => Described::row("Checked the pull request status", "read"), + ("ccd_pr", action @ ("bind_pr" | "unbind_pr" | "set_monitor" | "set_auto_merge")) => { + let url = field("url"); + let action = match action { + "bind_pr" => "bound", + "unbind_pr" => "unbound", + "set_monitor" => "monitored", + _ => "autoMerge", + }; + let mut card = json!({"card": "pullRequest", "action": action, "url": url}); + if let Some(number) = url.and_then(pr_number) { + card["number"] = json!(number); + } + match action { + "monitored" => card["autoFix"] = json!(input["auto_fix"] == true), + "autoMerge" => card["enabled"] = json!(input["enabled"] != false), + _ => {} + } + let title = url.map_or("Pull request".to_owned(), |url| format!("Pull request {url}")); + Described::card(title, card) + } + ("ccd_directory", action @ ("change_directory" | "request_directory")) => { + let path = field("path")?; + let action = if action == "change_directory" { "changed" } else { "requested" }; + Described::card( + format!("Working folder {path}"), + json!({"card": "directory", "action": action, "path": path}), + ) + } + ("ccd_view", "show_pane") => match field("path") { + Some(path) => Described { + locations: vec![json!({"path": path})], + ..Described::row(format!("Showed {path}"), "read") + }, + None => Described::row(format!("Showed the {} pane", field("pane").unwrap_or("side")), "other"), + }, + ("ccd_host", tool @ ("open_in_editor" | "reveal_path")) => { + let path = field("path")?; + let verb = if tool == "open_in_editor" { "Opened" } else { "Revealed" }; + let mut location = json!({"path": path}); + if let Some(line) = input["line"].as_u64().filter(|line| *line > 0) { + location["line"] = json!(line); + } + Described { locations: vec![location], ..Described::row(format!("{verb} {path}"), "read") } + } + ("visualize", "show_widget") => { + let title = humanize(field("title").unwrap_or("visualization")); + let code = field("widget_code").filter(|code| code.chars().count() <= WIDGET_CODE_LIMIT); + Described::card( + format!("Visualization: {title}"), + json!({"card": "widget", "title": title, "code": code}), + ) + } + ("visualize", "read_me") => Described::row("Read the visualization guide", "read"), + ("terminal", "run_in_terminal") => Described::row( + field("command").map_or("Ran a command in a terminal tab".to_owned(), first_line), + "execute", + ), + ("terminal", "read_terminal") => Described::row("Read a terminal tab", "read"), + ("Claude_Browser", "navigate") => Described::row( + field("url").map_or("Opened a page".to_owned(), |url| format!("Opened {url}")), + "fetch", + ), + (server, tool) => { + let area = match server { + "ccd_turn" | "ccd_session" | "ccd_session_mgmt" | "ccd_sidebar" | "ccd_window" + | "ccd_view" | "ccd_settings" | "ccd_host" | "ccd_pr" | "ccd_directory" + | "ccd_connectors" => "Claude Desktop", + "Claude_Browser" => "Browser", + "computer-use" => "Computer use", + "terminal" => "Terminal", + "scheduled-tasks" => "Scheduled tasks", + "visualize" => "Visualization", + // Desktop names a claude.ai connector by its id, which says nothing to the user. + _ if is_connector_id(server) => "Connector", + _ => return None, + }; + let kind = match tool.split('_').next() { + Some("list" | "get" | "read" | "search" | "find") => "read", + _ => "other", + }; + Described::row(format!("{area}: {}", humanize(tool)), kind) + } + }; + Some(described) +} + +/// A UUID in its 8-4-4-4-12 form. +fn is_connector_id(server: &str) -> bool { + let groups: Vec<_> = server.split('-').collect(); + groups.len() == 5 + && groups.iter().zip([8, 4, 4, 4, 12]).all(|(group, len)| { + group.len() == len && group.chars().all(|c| c.is_ascii_hexdigit()) + }) +} + +fn task_card(title: &str, summary: Option<&str>, prompt: Option<&str>, state: &str) -> Described { + Described::card( + format!("Suggested task: {title}"), + json!({ + "card": "task", + "title": title, + "summary": summary.map(|text| truncate(text, CARD_TEXT_LIMIT)), + "prompt": prompt.map(|text| truncate(text, CARD_TEXT_LIMIT)), + "state": state, + }), + ) +} + +fn set_card(update: &mut Value, card: Value) { + if !update["_meta"].is_object() { + update["_meta"] = json!({}); + } + if !update["_meta"]["lithe"].is_object() { + update["_meta"]["lithe"] = json!({}); + } + update["_meta"]["lithe"]["claudeDesktop"] = card; +} + +fn str_field<'a>(input: &'a Value, key: &str) -> Option<&'a str> { + input[key].as_str().map(str::trim).filter(|value| !value.is_empty()) +} + +/// The recap followed by the question, as separate paragraphs. fn wrap_up_text(input: &Value) -> Option { let text = ["recap", "ask"] .iter() - .filter_map(|key| input[key].as_str().map(str::trim).filter(|v| !v.is_empty())) + .filter_map(|key| str_field(input, key)) .collect::>() .join("\n\n"); (!text.is_empty()).then_some(text) } +/// Suggested replies that send a message. A label-only action only closes +/// Desktop's box, so it has nothing to offer here. +fn next_step_actions(input: &Value) -> Option> { + let actions: Vec = input["actions"] + .as_array()? + .iter() + .filter_map(|action| { + let label = str_field(action, "label")?; + let message = str_field(action, "message")?; + Some(json!({"label": label, "message": truncate(message, CARD_TEXT_LIMIT)})) + }) + .take(4) + .collect(); + (!actions.is_empty()).then_some(actions) +} + +/// Desktop asks the model to wrap a created PR's URL in `` so it +/// can draw a status card; elsewhere the tag is noise around a link. +fn present_pr_created(mut update: Value) -> Value { + const OPEN: &str = ""; + const CLOSE: &str = ""; + let Some(text) = update["content"]["text"].as_str().filter(|text| text.contains(OPEN)) else { + return update; + }; + let mut out = String::with_capacity(text.len()); + let mut rest = text; + while let Some(start) = rest.find(OPEN) { + let after = &rest[start + OPEN.len()..]; + let Some(end) = after.find(CLOSE) else { break }; + let url = after[..end].trim(); + out.push_str(&rest[..start]); + if url.starts_with("https://") && !url.contains(char::is_whitespace) { + match pr_number(url) { + Some(number) => out.push_str(&format!("[Pull request #{number}]({url})")), + None => out.push_str(&format!("[{url}]({url})")), + } + } else { + out.push_str(&rest[start..start + OPEN.len() + end + CLOSE.len()]); + } + rest = &after[end + CLOSE.len()..]; + } + out.push_str(rest); + update["content"]["text"] = json!(out); + update +} + +/// Number of a GitHub-style `…/pull/` URL. +fn pr_number(url: &str) -> Option { + let (_, tail) = url.rsplit_once("/pull/")?; + tail.split(['/', '#', '?']).next()?.parse().ok() +} + +/// Text of a tool result update's content blocks. +fn result_text(update: &Value) -> String { + update["content"] + .as_array() + .into_iter() + .flatten() + .filter_map(|item| item["content"]["text"].as_str()) + .collect::>() + .join("\n") +} + +/// First `task_` Desktop names in a tool result, past its `task_id:` label. +fn task_id_in(text: &str) -> Option { + text.match_indices("task_") + .map(|(start, _)| { + text[start..] + .chars() + .take_while(|c| c.is_ascii_alphanumeric() || *c == '_') + .collect::() + }) + .find(|id| id != "task_id" && id.len() > "task_".len()) +} + +/// `list_sessions` → `list sessions`. +fn humanize(name: &str) -> String { + name.replace(['_', '-'], " ") +} + +fn first_line(text: &str) -> String { + truncate(text.lines().next().unwrap_or(text), 200) +} + +fn truncate(text: &str, limit: usize) -> String { + match text.char_indices().nth(limit) { + Some((index, _)) => format!("{}…", &text[..index]), + None => text.to_owned(), + } +} + /// Applies one `update_plan` input and reports whether it changed anything. /// Inputs either replace the list (`items`), tick steps by one-based index /// (`done`) and start one (`start`), or `advance`: finish the running step @@ -156,6 +597,20 @@ mod tests { json!({"sessionUpdate": "tool_call_update", "toolCallId": id, "status": "completed"}) } + fn result(id: &str, text: &str) -> Value { + json!({"sessionUpdate": "tool_call_update", "toolCallId": id, "status": "completed", + "content": [{"type": "content", "content": {"type": "text", "text": text}}]}) + } + + fn one(mut shown: Vec) -> Value { + assert_eq!(shown.len(), 1, "{shown:?}"); + shown.remove(0) + } + + fn card(update: &Value) -> &Value { + &update["_meta"]["lithe"]["claudeDesktop"] + } + fn statuses(update: &Value) -> Vec<&str> { update["entries"] .as_array() @@ -166,16 +621,21 @@ mod tests { } #[test] - fn a_wrap_up_becomes_the_turn_answer_and_its_updates_are_absorbed() { + fn a_wrap_up_becomes_the_turn_answer_with_its_suggested_replies() { let mut presenter = Presenter::default(); let input = json!({"recap": "PR opened.", "ask": "Mark it ready?", - "actions": [{"label": "Yes", "message": "yes"}]}); - let shown = presenter - .present("s", call(WRAP_UP_TOOL, "w", input)) - .unwrap(); - assert_eq!(shown["sessionUpdate"], "agent_message_chunk"); - assert_eq!(shown["content"]["text"], "PR opened.\n\nMark it ready?"); - assert!(presenter.present("s", done("w")).is_none()); + "actions": [{"label": "Yes", "message": "yes"}, {"label": "Not now"}]}); + let shown = presenter.present("s", call(WRAP_UP_TOOL, "w", input)); + assert_eq!(shown.len(), 2); + assert_eq!(shown[0]["sessionUpdate"], "agent_message_chunk"); + assert_eq!(shown[0]["content"]["text"], "PR opened.\n\nMark it ready?"); + assert_eq!(shown[1]["toolCallId"], "w#next"); + assert_eq!(card(&shown[1])["card"], "nextStep"); + assert_eq!(card(&shown[1])["actions"], json!([{"label": "Yes", "message": "yes"}])); + assert!(presenter.present("s", done("w")).is_empty()); + + let recap_only = presenter.present("s", call(WRAP_UP_TOOL, "v", json!({"recap": "Done."}))); + assert_eq!(recap_only.len(), 1); } #[test] @@ -186,24 +646,16 @@ mod tests { {"content": "b", "status": "pending"}, {"content": "c", "status": "pending"}, ]}); - let set = presenter - .present("s", call(UPDATE_PLAN_TOOL, "p1", items)) - .unwrap(); + let set = one(presenter.present("s", call(UPDATE_PLAN_TOOL, "p1", items))); assert_eq!(set["sessionUpdate"], "plan"); assert_eq!(statuses(&set), ["in_progress", "pending", "pending"]); - assert!(presenter.present("s", done("p1")).is_none()); + assert!(presenter.present("s", done("p1")).is_empty()); - let advanced = presenter - .present("s", call(UPDATE_PLAN_TOOL, "p2", json!({"advance": true}))) - .unwrap(); + let advanced = one(presenter.present("s", call(UPDATE_PLAN_TOOL, "p2", json!({"advance": true})))); assert_eq!(statuses(&advanced), ["completed", "in_progress", "pending"]); - let ticked = presenter - .present( - "s", - call(UPDATE_PLAN_TOOL, "p3", json!({"done": [2], "start": 3})), - ) - .unwrap(); + let ticked = call(UPDATE_PLAN_TOOL, "p3", json!({"done": [2], "start": 3})); + let ticked = one(presenter.present("s", ticked)); assert_eq!(statuses(&ticked), ["completed", "completed", "in_progress"]); } @@ -211,12 +663,143 @@ mod tests { fn other_tools_empty_calls_and_unknown_sessions_pass_through() { let mut presenter = Presenter::default(); let empty = call(WRAP_UP_TOOL, "w", json!({})); - assert_eq!(presenter.present("s", empty.clone()), Some(empty)); - assert_eq!(presenter.present("s", done("w")), Some(done("w"))); - // An increment without a list to apply it to stays a plain tool call. + assert_eq!(presenter.present("s", empty.clone()), vec![empty]); + assert_eq!(presenter.present("s", done("w")), vec![done("w")]); + // An increment without a list to apply it to stays a tool call, now titled. let orphan = call(UPDATE_PLAN_TOOL, "p", json!({"advance": true})); - assert_eq!(presenter.present("t", orphan.clone()), Some(orphan)); + assert_eq!(one(presenter.present("t", orphan))["title"], "Updated the task list"); + let bash = call("Bash", "b", json!({"command": "ls"})); + assert_eq!(presenter.present("s", bash.clone()), vec![bash]); + let other_server = call("mcp__dbx__dbx_list_connections", "d", json!({})); + assert_eq!(presenter.present("s", other_server.clone()), vec![other_server]); + // A claude.ai connector is named by its id; a server merely resembling one is not. + let connector = call("mcp__1a59c906-04da-521d-bda7-7f71b9f9e01c__read", "k", json!({})); + let connector = one(presenter.present("s", connector)); + assert_eq!((connector["title"].as_str(), connector["kind"].as_str()), (Some("Connector: read"), Some("read"))); + let lookalike = call("mcp__1a59c906-04da-521d-bda7__read", "l", json!({})); + assert_eq!(presenter.present("s", lookalike.clone()), vec![lookalike]); let text = json!({"sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": "hi"}}); - assert_eq!(presenter.present("s", text.clone()), Some(text)); + assert_eq!(presenter.present("s", text.clone()), vec![text]); + } + + #[test] + fn chapters_pull_requests_folders_and_widgets_become_cards_with_readable_titles() { + let mut presenter = Presenter::default(); + let input = json!({"title": "Core", "summary": "Rust first"}); + let chapter = one(presenter.present("s", call("mcp__ccd_session__mark_chapter", "c", input))); + assert_eq!(chapter["title"], "Chapter: Core"); + assert_eq!(card(&chapter), &json!({"card": "chapter", "title": "Core", "summary": "Rust first"})); + // Card calls keep their own results, so a failed call stays visible as one. + assert_eq!(presenter.present("s", done("c")), vec![done("c")]); + // A later update restating the adapter's title keeps the readable one. + let restated = json!({"sessionUpdate": "tool_call_update", "toolCallId": "c", + "title": "mcp__ccd_session__mark_chapter", "kind": "other"}); + assert_eq!(one(presenter.present("s", restated))["title"], "Chapter: Core"); + + let input = json!({"url": "https://github.com/o/r/pull/31", "auto_fix": true}); + let pr = one(presenter.present("s", call("mcp__ccd_pr__set_monitor", "p", input))); + assert_eq!(card(&pr)["card"], "pullRequest"); + assert_eq!(card(&pr)["action"], "monitored"); + assert_eq!(card(&pr)["number"], 31); + assert_eq!(card(&pr)["autoFix"], true); + + let input = json!({"path": "/w/other"}); + let folder = one(presenter.present("s", call("mcp__ccd_directory__change_directory", "d", input))); + assert_eq!(card(&folder), &json!({"card": "directory", "action": "changed", "path": "/w/other"})); + + let input = json!({"title": "vcs_layers", "widget_code": ""}); + let widget = one(presenter.present("s", call("mcp__visualize__show_widget", "v", input))); + assert_eq!(widget["title"], "Visualization: vcs layers"); + assert_eq!(card(&widget)["code"], ""); + } + + #[test] + fn panes_and_editor_requests_become_file_locations() { + let mut presenter = Presenter::default(); + let input = json!({"pane": "file", "path": "notes/a.md"}); + let pane = one(presenter.present("s", call("mcp__ccd_view__show_pane", "v", input))); + assert_eq!(pane["kind"], "read"); + assert_eq!(pane["locations"], json!([{"path": "notes/a.md"}])); + let input = json!({"path": "/w/a.rs", "line": 12}); + let editor = one(presenter.present("s", call("mcp__ccd_host__open_in_editor", "e", input))); + assert_eq!(editor["locations"], json!([{"path": "/w/a.rs", "line": 12}])); + let layout = one(presenter.present("s", call("mcp__ccd_window__get_window_layout", "l", json!({})))); + assert_eq!(layout["title"], "Claude Desktop: get window layout"); + assert_eq!(layout["kind"], "read"); + let shot = one(presenter.present("s", call("mcp__computer-use__app_screenshot", "x", json!({})))); + assert_eq!(shot["title"], "Computer use: app screenshot"); + } + + #[test] + fn task_cards_follow_their_proposal_withdrawal_and_start() { + let mut presenter = Presenter::default(); + let input = json!({"title": "Fix", "tldr": "Why", "prompt": "Do it"}); + let proposed = one(presenter.present("s", call("mcp__ccd_session__spawn_task", "t1", input))); + assert_eq!(card(&proposed)["state"], "proposed"); + assert_eq!(card(&proposed)["prompt"], "Do it"); + presenter.present("s", result("t1", "Noted (position 1, task_id: task_9edecafe). A chip is showing")); + + // The withdrawal call itself is absorbed; its result restates the card. + let dismiss = call("mcp__ccd_session__dismiss_task", "x", json!({"task_id": "task_9edecafe"})); + assert!(presenter.present("s", dismiss).is_empty()); + let withdrawn = one(presenter.present("s", result("x", "Task task_9edecafe withdrawn"))); + assert_eq!(withdrawn["toolCallId"], "t1"); + assert_eq!(card(&withdrawn)["state"], "withdrawn"); + + let input = json!({"initiation": "own_initiative", "title": "Move", "prompt": "Go"}); + presenter.present("s", call("mcp__ccd_session__start_session", "t2", input)); + presenter.present("s", result("t2", "Proposed \"Move\" (task_id: task_0f1ad1a7). The user sees a card")); + let start = call("mcp__ccd_session__start_session", "y", json!({"proposal_id": "task_0f1ad1a7"})); + assert!(presenter.present("s", start).is_empty()); + assert_eq!(card(&one(presenter.present("s", done("y"))))["state"], "started"); + + let dismiss = call("mcp__ccd_session__dismiss_task", "z", json!({"task_id": "task_0f1ad1a7"})); + presenter.present("s", dismiss); + let late = one(presenter.present("s", result("z", "Task task_0f1ad1a7 was already started"))); + assert_eq!(card(&late)["state"], "started"); + + let input = json!({"title": "Stats", "remaining_plan": "\nDone: x\nNext: y"}); + let handoff = one(presenter.present("s", call("mcp__ccd_session__hand_off_to_session", "h", input))); + assert_eq!(card(&handoff)["state"], "handedOff"); + assert_eq!(card(&handoff)["summary"], "Done: x"); + } + + #[test] + fn failed_task_actions_stay_visible_and_progress_does_not_change_the_card() { + let mut presenter = Presenter::default(); + presenter.present("s", call("mcp__ccd_session__spawn_task", "t1", json!({"title": "Fix", "prompt": "Do it"}))); + presenter.present("s", result("t1", "Noted (task_id: task_9edecafe).")); + + let dismiss = call("mcp__ccd_session__dismiss_task", "x", json!({"task_id": "task_9edecafe"})); + assert!(presenter.present("s", dismiss).is_empty()); + let progress = json!({"sessionUpdate": "tool_call_update", "toolCallId": "x", "status": "in_progress"}); + assert!(presenter.present("s", progress).is_empty(), "Progress must not withdraw the card"); + let failed = json!({"sessionUpdate": "tool_call_update", "toolCallId": "x", "status": "failed", + "content": [{"type": "content", "content": {"type": "text", "text": "Denied"}}]}); + let shown = presenter.present("s", failed); + assert_eq!(shown.len(), 2, "{shown:?}"); + assert_eq!(shown[0]["sessionUpdate"], "tool_call"); + assert_eq!(shown[0]["toolCallId"], "x"); + assert_eq!(shown[0]["title"], "Withdrew a suggested task"); + assert_eq!(shown[1]["status"], "failed"); + + let start = call("mcp__ccd_session__start_session", "y", json!({"proposal_id": "task_9edecafe"})); + assert!(presenter.present("s", start).is_empty()); + let failed = json!({"sessionUpdate": "tool_call_update", "toolCallId": "y", "status": "failed"}); + assert_eq!(presenter.present("s", failed)[0]["title"], "Started a suggested task"); + } + + #[test] + fn created_pull_request_tags_become_links() { + let mut presenter = Presenter::default(); + let chunk = |text: &str| { + json!({"sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": text}}) + }; + let tagged = chunk("Opened.\nhttps://github.com/o/r/pull/7\n"); + let shown = one(presenter.present("s", tagged)); + assert_eq!(shown["content"]["text"], "Opened.\n[Pull request #7](https://github.com/o/r/pull/7)\n"); + // Anything but a URL is left as the model wrote it. + let odd = chunk("not a url"); + assert_eq!(one(presenter.present("s", odd.clone())), odd); } } diff --git a/rust/lithe-agent-host/src/claude_earlier_history.rs b/rust/lithe-agent-host/src/claude_earlier_history.rs index c5ccf5077..288ef796c 100644 --- a/rust/lithe-agent-host/src/claude_earlier_history.rs +++ b/rust/lithe-agent-host/src/claude_earlier_history.rs @@ -16,6 +16,8 @@ use std::path::{Path, PathBuf}; use serde_json::{json, Value}; +use crate::claude_desktop; + /// Larger transcripts are skipped rather than read: the recap alone is shown. const MAX_TRANSCRIPT_BYTES: u64 = 128 * 1024 * 1024; /// Upper bound on emitted updates, keeping the replay event queue bounded. @@ -255,7 +257,12 @@ impl Converter { "title": title(name, input), "kind": kind(name), "status": "completed", - "rawInput": bounded(input), + // A card, plan or reply is built from this input, so it is not cut to a preview. + "rawInput": if claude_desktop::carries_card_input(name) { + bounded(input, claude_desktop::CARD_INPUT_LIMIT) + } else { + bounded(input, TEXT_LIMIT) + }, })); } @@ -322,15 +329,15 @@ fn title(name: &str, input: &Value) -> String { } } -/// Copy of `value` whose strings are cut to [`TEXT_LIMIT`], so a whole-file +/// Copy of `value` whose strings are cut to `limit` characters, so a whole-file /// `Write` input does not travel through the replay queue. -fn bounded(value: &Value) -> Value { +fn bounded(value: &Value, limit: usize) -> Value { match value { - Value::String(text) => Value::String(truncate(text)), - Value::Array(items) => Value::Array(items.iter().map(bounded).collect()), - Value::Object(map) => { - Value::Object(map.iter().map(|(k, v)| (k.clone(), bounded(v))).collect()) - } + Value::String(text) => Value::String(truncate_to(text, limit)), + Value::Array(items) => Value::Array(items.iter().map(|item| bounded(item, limit)).collect()), + Value::Object(map) => Value::Object( + map.iter().map(|(k, v)| (k.clone(), bounded(v, limit))).collect(), + ), other => other.clone(), } } @@ -455,6 +462,26 @@ mod tests { assert_eq!(updates[1]["messageId"], "q1"); } + #[test] + fn card_inputs_survive_the_replay_whole_while_other_tool_inputs_are_cut() { + let widget = format!("{}", "x".repeat(TEXT_LIMIT * 2)); + let (dir, cwd) = transcript(&[ + json!({"type": "assistant", "message": {"content": [ + {"type": "tool_use", "id": "v", "name": "mcp__visualize__show_widget", + "input": {"title": "chart", "widget_code": widget}}, + {"type": "tool_use", "id": "w", "name": "Write", + "input": {"file_path": "/a/b.rs", "content": "y".repeat(TEXT_LIMIT * 2)}}, + ]}}), + boundary(), + ]); + let updates = earlier_updates(dir.path(), &cwd, "abc-1"); + let mut presenter = claude_desktop::Presenter::default(); + let shown = presenter.present("s", updates[0].clone()); + assert_eq!(shown[0]["_meta"]["lithe"]["claudeDesktop"]["code"], widget.as_str()); + let write = updates[1]["rawInput"]["content"].as_str().unwrap(); + assert_eq!(write.chars().count(), TEXT_LIMIT + 1, "Ordinary inputs stay previews"); + } + #[test] fn pasted_screenshots_stay_on_the_user_message_that_references_them() { let (dir, cwd) = transcript(&[ diff --git a/rust/lithe-agent-host/src/lib.rs b/rust/lithe-agent-host/src/lib.rs index 35a861962..e90b9f7f2 100644 --- a/rust/lithe-agent-host/src/lib.rs +++ b/rust/lithe-agent-host/src/lib.rs @@ -1380,33 +1380,33 @@ where let Some(update) = update_workspace.present_update(update) else { return Ok(()); }; - let Some(update) = update_desktop.lock().map_err(|_| internal("Replay presentation is unavailable"))?.present(&session_id, update) else { - return Ok(()); - }; - if update["sessionUpdate"] == "usage_update" { - if let Some(recovery) = &context_updates { recovery.prefer_upstream_report(&session_id); } - } - let mut reconnect = None; - if let Ok(turns) = update_turns.lock() { - if let Some(turn) = owner.as_ref().and_then(|id| turns.get(id)) { - if owner.as_deref() == Some(session_id.as_str()) { turn.steering.observe(&update); } - turn.retry.send_modify(|state| { - if prompt_retry::is_progress(&update) { state.progress(); } - // A child failure is the child's outcome, not a new - // failure or retry of the main Agent's model call. - if owner.as_deref() == Some(session_id.as_str()) { - state.observe_failure(&update); - reconnect = state.observe_native_retry(&update); - } - }); + let presented = update_desktop.lock().map_err(|_| internal("Replay presentation is unavailable"))?.present(&session_id, update); + for update in presented { + if update["sessionUpdate"] == "usage_update" { + if let Some(recovery) = &context_updates { recovery.prefer_upstream_report(&session_id); } + } + let mut reconnect = None; + if let Ok(turns) = update_turns.lock() { + if let Some(turn) = owner.as_ref().and_then(|id| turns.get(id)) { + if owner.as_deref() == Some(session_id.as_str()) { turn.steering.observe(&update); } + turn.retry.send_modify(|state| { + if prompt_retry::is_progress(&update) { state.progress(); } + // A child failure is the child's outcome, not a new + // failure or retry of the main Agent's model call. + if owner.as_deref() == Some(session_id.as_str()) { + state.observe_failure(&update); + reconnect = state.observe_native_retry(&update); + } + }); + } + } + updates(AgentEvent::Update { + session_id: session_id.clone(), + update, + }); + if let Some((turn_id, attempt, max_attempts)) = reconnect { + updates(AgentEvent::TurnRetrying { session_id: session_id.clone(), turn_id, attempt, max_attempts }); } - } - updates(AgentEvent::Update { - session_id: session_id.clone(), - update, - }); - if let Some((turn_id, attempt, max_attempts)) = reconnect { - updates(AgentEvent::TurnRetrying { session_id, turn_id, attempt, max_attempts }); } } Ok(()) @@ -1697,7 +1697,7 @@ where .unwrap_or_default(); // Collected first: the guard must not live across `emit`. let updates: Vec<_> = match desktop.lock() { - Ok(mut desktop) => updates.into_iter().filter_map(|u| desktop.present(&session_id, u)).collect(), + Ok(mut desktop) => updates.into_iter().flat_map(|u| desktop.present(&session_id, u)).collect(), Err(_) => updates, }; for update in updates { diff --git a/shared/contracts/application-boundary.md b/shared/contracts/application-boundary.md index 085371641..d4b37cf69 100644 --- a/shared/contracts/application-boundary.md +++ b/shared/contracts/application-boundary.md @@ -794,6 +794,13 @@ JDT-owned workspace build paths and the existing platform-owned JDT state. - 状态隔离到会话,新进度清除重试显示,完成/失败/断连清理本轮状态,正在停止时不因迟到进度回退;不得显示未经脱敏的原始错误或从文本猜测重试次数。请求仍等待上游完成/取消确认,既有十分钟 prompt 上限和十秒取消上限不变。 - 这次交付 macOS 展示;Windows 对话 UI 的消费与真实验收仍待完成。展示状态只在内存中,不写入 bundle、安装目录或上游历史。 +### Claude Desktop 机制卡片 + +- Host 识别 Claude Desktop 注入的 MCP 工具(`ccd_*`、`visualize`、`terminal`、`Claude_Browser`、`computer-use`、`scheduled-tasks` 与按 UUID 命名的 claude.ai 连接器),为每个调用给出可读的英文 `title` 和 `kind`;不认识的 MCP 服务原样透传。 +- 有交互含义的调用在 `tool_call` / `tool_call_update` 的 `_meta.lithe.claudeDesktop` 附加卡片,字段 `card` 决定类型:`chapter {title, summary?}`、`nextStep {actions: [{label, message}] ≤4}`、`task {title, summary?, prompt?, state: proposed|withdrawn|started|handedOff}`、`pullRequest {action: bound|unbound|monitored|autoMerge, url, number?, autoFix?, enabled?}`、`directory {action: changed|requested, path}`、`widget {title, code ≤256 KiB 字符}`。文本字段截断到 16 KiB 字符;超限的 `widget.code` 置空。 +- `nextStep` 是 Host 另发的已完成调用,id 为原 `wrap_up` 调用 id 加 `#next`;任务撤回或启动的结果到达后,Host 以原卡片调用 id 补发整张卡片。后到的卡片整体替换先前的卡片;不带卡片的更新不清除卡片。 +- 客户端只在调用状态为 `completed` 时显示卡片;未知 `card` 类型、缺字段或非 http(s) 的 PR 链接退回普通工具行。卡片动作只填写输入框,不代替用户发送。可视化内容必须在禁止网络加载、非持久存储、禁止导航的隔离网页中渲染。 + Agent skill management groups files by Global/Project and retains their Agent provenance. Codex-reported capabilities and Claude's partial directory inventory diff --git a/shared/i18n/locales/en-US.json b/shared/i18n/locales/en-US.json index 7d8d4260d..069b094e6 100644 --- a/shared/i18n/locales/en-US.json +++ b/shared/i18n/locales/en-US.json @@ -8864,7 +8864,25 @@ "settings.declarativePlugins.settingEffect.reload": "takes effect when the plugin model reloads", "settings.declarativePlugins.settingEffect.nextRun": "takes effect on the next run", "native.databaseIsNowAPluginInstallItFromTheMarketplace": "Database is now a plugin. Install it from the Marketplace, and your saved connections and passwords move into it.", - "native.openMarketplace": "Open Marketplace" + "native.openMarketplace": "Open Marketplace", + "native.suggestedReplies": "Suggested replies", + "native.changedTheWorkingDirectoryTo": "Changed the working directory to {arg1}", + "native.askedForAccessTo": "Asked for access to {arg1}", + "native.chapter": "Chapter: {arg1}", + "native.startInNewConversation": "Start in new conversation", + "native.suggested": "Suggested", + "native.withdrawn": "Withdrawn", + "native.startedInClaudeDesktop": "Started in Claude Desktop", + "native.handedOff": "Handed off", + "native.linkedThisConversationToAPullRequest": "Linked this conversation to a pull request", + "native.unlinkedThePullRequest": "Unlinked the pull request", + "native.watchingPullRequestChecksAndFixingFailures": "Watching pull request checks and fixing failures", + "native.watchingPullRequestChecks": "Watching pull request checks", + "native.turnedOffAutoMerge": "Turned off auto-merge", + "native.turnedOnAutoMerge": "Turned on auto-merge", + "native.visualization": "Visualization", + "native.showVisualization": "Show visualization", + "native.hideVisualization": "Hide visualization" }, "overrides": { "windows": { diff --git a/shared/i18n/locales/zh-CN.json b/shared/i18n/locales/zh-CN.json index 0f523922b..366178ec0 100644 --- a/shared/i18n/locales/zh-CN.json +++ b/shared/i18n/locales/zh-CN.json @@ -8864,7 +8864,25 @@ "settings.declarativePlugins.settingEffect.reload": "插件模型重新加载后生效", "settings.declarativePlugins.settingEffect.nextRun": "下次运行生效", "native.databaseIsNowAPluginInstallItFromTheMarketplace": "数据库已改为插件。从插件市场安装后,已保存的连接和密码会自动迁入。", - "native.openMarketplace": "打开插件市场" + "native.openMarketplace": "打开插件市场", + "native.suggestedReplies": "建议的回复", + "native.changedTheWorkingDirectoryTo": "已将工作目录切换到 {arg1}", + "native.askedForAccessTo": "请求访问 {arg1}", + "native.chapter": "章节:{arg1}", + "native.startInNewConversation": "在新对话中开始", + "native.suggested": "建议", + "native.withdrawn": "已撤回", + "native.startedInClaudeDesktop": "已在 Claude Desktop 中开始", + "native.handedOff": "已移交", + "native.linkedThisConversationToAPullRequest": "已将此对话关联到拉取请求", + "native.unlinkedThePullRequest": "已取消关联拉取请求", + "native.watchingPullRequestChecksAndFixingFailures": "正在关注拉取请求检查并修复失败项", + "native.watchingPullRequestChecks": "正在关注拉取请求检查", + "native.turnedOffAutoMerge": "已关闭自动合并", + "native.turnedOnAutoMerge": "已开启自动合并", + "native.visualization": "可视化", + "native.showVisualization": "显示可视化", + "native.hideVisualization": "隐藏可视化" }, "overrides": { "macos": { diff --git a/shared/i18n/messages.json b/shared/i18n/messages.json index e64e202c0..5f91ea660 100644 --- a/shared/i18n/messages.json +++ b/shared/i18n/messages.json @@ -82758,6 +82758,219 @@ "key": "Open Marketplace", "arguments": [] } + }, + { + "id": "native.suggestedReplies", + "description": "Native UI: Suggested replies", + "platforms": [ + "macos" + ], + "macos": { + "key": "Suggested replies", + "arguments": [] + } + }, + { + "id": "native.changedTheWorkingDirectoryTo", + "description": "Native UI: Changed the working directory to %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Changed the working directory to %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.askedForAccessTo", + "description": "Native UI: Asked for access to %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Asked for access to %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.chapter", + "description": "Native UI: Chapter: %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Chapter: %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.startInNewConversation", + "description": "Native UI: Start in new conversation", + "platforms": [ + "macos" + ], + "macos": { + "key": "Start in new conversation", + "arguments": [] + } + }, + { + "id": "native.suggested", + "description": "Native UI: Suggested", + "platforms": [ + "macos" + ], + "macos": { + "key": "Suggested", + "arguments": [] + } + }, + { + "id": "native.withdrawn", + "description": "Native UI: Withdrawn", + "platforms": [ + "macos" + ], + "macos": { + "key": "Withdrawn", + "arguments": [] + } + }, + { + "id": "native.startedInClaudeDesktop", + "description": "Native UI: Started in Claude Desktop", + "platforms": [ + "macos" + ], + "macos": { + "key": "Started in Claude Desktop", + "arguments": [] + } + }, + { + "id": "native.handedOff", + "description": "Native UI: Handed off", + "platforms": [ + "macos" + ], + "macos": { + "key": "Handed off", + "arguments": [] + } + }, + { + "id": "native.linkedThisConversationToAPullRequest", + "description": "Native UI: Linked this conversation to a pull request", + "platforms": [ + "macos" + ], + "macos": { + "key": "Linked this conversation to a pull request", + "arguments": [] + } + }, + { + "id": "native.unlinkedThePullRequest", + "description": "Native UI: Unlinked the pull request", + "platforms": [ + "macos" + ], + "macos": { + "key": "Unlinked the pull request", + "arguments": [] + } + }, + { + "id": "native.watchingPullRequestChecksAndFixingFailures", + "description": "Native UI: Watching pull request checks and fixing failures", + "platforms": [ + "macos" + ], + "macos": { + "key": "Watching pull request checks and fixing failures", + "arguments": [] + } + }, + { + "id": "native.watchingPullRequestChecks", + "description": "Native UI: Watching pull request checks", + "platforms": [ + "macos" + ], + "macos": { + "key": "Watching pull request checks", + "arguments": [] + } + }, + { + "id": "native.turnedOffAutoMerge", + "description": "Native UI: Turned off auto-merge", + "platforms": [ + "macos" + ], + "macos": { + "key": "Turned off auto-merge", + "arguments": [] + } + }, + { + "id": "native.turnedOnAutoMerge", + "description": "Native UI: Turned on auto-merge", + "platforms": [ + "macos" + ], + "macos": { + "key": "Turned on auto-merge", + "arguments": [] + } + }, + { + "id": "native.visualization", + "description": "Native UI: Visualization", + "platforms": [ + "macos" + ], + "macos": { + "key": "Visualization", + "arguments": [] + } + }, + { + "id": "native.showVisualization", + "description": "Native UI: Show visualization", + "platforms": [ + "macos" + ], + "macos": { + "key": "Show visualization", + "arguments": [] + } + }, + { + "id": "native.hideVisualization", + "description": "Native UI: Hide visualization", + "platforms": [ + "macos" + ], + "macos": { + "key": "Hide visualization", + "arguments": [] + } } ] } diff --git a/shared/platform-feature-matrix/features/agent-claude-desktop-mechanisms.json b/shared/platform-feature-matrix/features/agent-claude-desktop-mechanisms.json new file mode 100644 index 000000000..bc6629ed4 --- /dev/null +++ b/shared/platform-feature-matrix/features/agent-claude-desktop-mechanisms.json @@ -0,0 +1,31 @@ +{ + "id": "agent-claude-desktop-mechanisms", + "area": "AI", + "group": "Agent 对话", + "capability": "在 Lithe 中打开 Claude Desktop 开始的 Claude Code 会话时,Desktop 专属机制按 Desktop 的方式呈现:每轮总结(wrap_up)显示为 Agent 回复,建议回复显示为按钮(仅最新一轮、未在回复时出现,点击填入输入框);章节显示为分隔线;建议任务、启动会话与移交显示为任务卡片并随撤回/启动更新状态,可在新对话中开始;PR 绑定、CI 监控、自动合并显示为带链接的说明行;切换或申请目录显示为说明行;show_widget 可视化在禁网、非持久、禁止导航的隔离网页中展开显示; 变为 PR 链接;Desktop、浏览器、电脑操作、终端、定时任务与 claude.ai 连接器的其余工具显示可读标题;CI 监控事件与跨会话通知显示为折叠上下文。被拒绝或失败的 Desktop 调用保留为失败的工具行;CLI 会话行为不变", + "macos": { + "evidence": [ + "rust/lithe-agent-host/src/claude_desktop.rs", + "rust/lithe-agent-host/src/lib.rs", + "macos/Sources/LitheAgentConversationModule/Application/AgentDesktopCard.swift", + "macos/Sources/LitheAgentConversationModule/Application/AgentSessionConfiguration.swift", + "macos/Sources/LitheAgentConversationModule/Application/AgentImage.swift", + "macos/Sources/Lithe/Views/Agent/AgentDesktopCardView.swift", + "macos/Sources/Lithe/Views/Agent/AgentTranscriptView.swift", + "macos/Tests/LitheTests/AgentDesktopCardTests.swift" + ], + "implementationStatus": "implemented", + "verificationStatus": "pending" + }, + "windows": { + "evidence": [ + "rust/lithe-agent-host/src/claude_desktop.rs", + "windows/tauri/src/features/agent/components/agent-transcript.tsx" + ], + "implementationStatus": "partial", + "verificationStatus": "pending" + }, + "owner": "Agent", + "verification": "macOS:在 Lithe 中恢复一个 Claude Desktop 开始、含章节、建议回复、建议任务(含撤回与启动)、PR 监控、目录切换与 show_widget 的会话,确认各卡片按顺序出现、被拒绝的调用留在折叠过程中显示失败;建议回复只在最后一轮且未在回复时出现,点击后文字进入输入框而不发送;任务卡片“在新对话中开始”新建对话并填入提示;PR 链接在浏览器打开;可视化展开后正常显示内联 SVG/HTML、不发起网络请求、点击链接交给浏览器;深浅主题与 300pt 窄面板布局不溢出。CLI 会话中同样的工具不存在,转录不受影响。运行 AgentDesktopCardTests 与 cargo test -p lithe-agent-host claude_desktop。Windows:共用宿主,Desktop 工具显示可读标题与类型,卡片尚未绘制,退回普通工具行。 可视化页面里点链接不会打开浏览器,页面脚本自己 click 链接也不会;页面刚展开时调用 sendPrompt 不会改动输入框草稿,用户在页面内点击后才会填入。任务撤回或启动失败时,原调用和失败原因在折叠过程里可见,卡片状态不变。压缩前历史里超过 4000 字符的可视化和任务提示完整显示。", + "notes": "2026-10-25:宿主在 `_meta.lithe.claudeDesktop` 附加卡片描述并为所有 Desktop 工具提供可读标题;macOS 原生绘制卡片。Windows 只获得可读工具行,卡片待对齐。用本机 96 个含 Desktop 工具的真实会话离线回放,产出章节 19、建议回复 154、建议任务 30(6 次启动、4 次撤回状态更新)、PR 3、目录 7、可视化 7、计划 854。" +}