@deepseek-harness-tui/dsh-tui 0.9.0 → 0.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +69 -256
- package/bin/dsh-tui.js +172 -5
- package/cordis.patch.yml +16 -4
- package/dsh-ecosystem-spec/protocols/profile-definitions.d.ts +13 -13
- package/dsh-ecosystem-spec/protocols/profile-definitions.js +59 -59
- package/dsh-ecosystem-spec/protocols/tui-channel-http.d.ts +15 -15
- package/dsh-ecosystem-spec/protocols/tui-channel-http.js +32 -32
- package/dsh-ecosystem-spec/protocols/tui-channel.d.ts +59 -59
- package/dsh-ecosystem-spec/protocols/tui-channel.js +166 -166
- package/dsh-ecosystem-spec/protocols/tui-contributions.d.ts +42 -42
- package/dsh-ecosystem-spec/protocols/tui-contributions.js +143 -143
- package/dsh-ecosystem-spec/registry/README.md +14 -14
- package/dsh-ecosystem-spec/registry/host-descriptor.tui.example.json +56 -56
- package/dsh-ecosystem-spec/registry/permissions-0.1.json +13 -13
- package/dsh-ecosystem-spec/registry/registry-0.15.json +149 -149
- package/lib/types/cc/hyperlink.d.ts +7 -1
- package/lib/types/cc/hyperlink.d.ts.map +1 -1
- package/lib/types/cc/hyperlink.js +3 -2
- package/lib/types/cc/markdown.d.ts.map +1 -1
- package/lib/types/cc/markdown.js +33 -3
- package/lib/types/cc/sessionColors.d.ts +27 -0
- package/lib/types/cc/sessionColors.d.ts.map +1 -0
- package/lib/types/cc/sessionColors.js +39 -0
- package/lib/types/commands.d.ts.map +1 -1
- package/lib/types/commands.js +6 -0
- package/lib/types/components/AutoRecapRow.d.ts +23 -0
- package/lib/types/components/AutoRecapRow.d.ts.map +1 -0
- package/lib/types/components/AutoRecapRow.js +23 -0
- package/lib/types/components/ColorPicker.d.ts +17 -0
- package/lib/types/components/ColorPicker.d.ts.map +1 -0
- package/lib/types/components/ColorPicker.js +20 -0
- package/lib/types/components/CommandSuggestions.d.ts +3 -1
- package/lib/types/components/CommandSuggestions.d.ts.map +1 -1
- package/lib/types/components/ContextBarView.d.ts +33 -0
- package/lib/types/components/ContextBarView.d.ts.map +1 -0
- package/lib/types/components/ContextBarView.js +46 -0
- package/lib/types/components/EffortInputBorder.d.ts +12 -1
- package/lib/types/components/EffortInputBorder.d.ts.map +1 -1
- package/lib/types/components/EffortInputBorder.js +22 -5
- package/lib/types/components/FileActionsPanel.d.ts +22 -0
- package/lib/types/components/FileActionsPanel.d.ts.map +1 -0
- package/lib/types/components/FileActionsPanel.js +28 -0
- package/lib/types/components/FileSuggestions.d.ts +3 -1
- package/lib/types/components/FileSuggestions.d.ts.map +1 -1
- package/lib/types/components/MessageList.d.ts +3 -1
- package/lib/types/components/MessageList.d.ts.map +1 -1
- package/lib/types/components/MessageList.js +13 -6
- package/lib/types/components/ModelPicker.d.ts +26 -4
- package/lib/types/components/ModelPicker.d.ts.map +1 -1
- package/lib/types/components/ModelPicker.js +26 -6
- package/lib/types/components/PromptInput.d.ts.map +1 -1
- package/lib/types/components/PromptInput.js +365 -57
- package/lib/types/components/RecapPanel.d.ts +25 -0
- package/lib/types/components/RecapPanel.d.ts.map +1 -0
- package/lib/types/components/RecapPanel.js +55 -0
- package/lib/types/components/ScrollbarGutter.d.ts.map +1 -1
- package/lib/types/components/ScrollbarGutter.js +55 -4
- package/lib/types/components/SubagentCard.d.ts.map +1 -1
- package/lib/types/components/SubagentCard.js +6 -4
- package/lib/types/components/SuggestionCard.d.ts +4 -2
- package/lib/types/components/SuggestionCard.d.ts.map +1 -1
- package/lib/types/components/TimelineRail.d.ts.map +1 -1
- package/lib/types/components/TimelineRail.js +9 -2
- package/lib/types/components/design-system/ThemeProvider.d.ts +7 -0
- package/lib/types/components/design-system/ThemeProvider.d.ts.map +1 -1
- package/lib/types/components/design-system/ThemeProvider.js +2 -1
- package/lib/types/components/messages/AssistantToolUseMessage.d.ts +8 -1
- package/lib/types/components/messages/AssistantToolUseMessage.d.ts.map +1 -1
- package/lib/types/components/messages/AssistantToolUseMessage.js +36 -8
- package/lib/types/components/questions/AskUserQuestionPanel.d.ts +7 -3
- package/lib/types/components/questions/AskUserQuestionPanel.d.ts.map +1 -1
- package/lib/types/components/questions/AskUserQuestionPanel.js +41 -9
- package/lib/types/dsh-adapter/channel.d.ts +101 -2
- package/lib/types/dsh-adapter/channel.d.ts.map +1 -1
- package/lib/types/dsh-adapter/channel.js +969 -93
- package/lib/types/dsh-adapter/compat/index.d.ts +8 -6
- package/lib/types/dsh-adapter/compat/index.d.ts.map +1 -1
- package/lib/types/dsh-adapter/compat/index.js +8 -6
- package/lib/types/dsh-adapter/compat/sessionLog.d.ts +150 -0
- package/lib/types/dsh-adapter/compat/sessionLog.d.ts.map +1 -1
- package/lib/types/dsh-adapter/compat/sessionLog.js +518 -41
- package/lib/types/dsh-adapter/contract.d.ts +1 -1
- package/lib/types/dsh-adapter/contract.d.ts.map +1 -1
- package/lib/types/dsh-adapter/contract.js +1 -0
- package/lib/types/dsh-adapter/extension-events.d.ts +1 -1
- package/lib/types/dsh-adapter/extension-events.d.ts.map +1 -1
- package/lib/types/dsh-adapter/index.d.ts +10 -0
- package/lib/types/dsh-adapter/index.d.ts.map +1 -1
- package/lib/types/dsh-adapter/index.js +5 -0
- package/lib/types/dsh-adapter/plugin.d.ts.map +1 -1
- package/lib/types/dsh-adapter/plugin.js +317 -8
- package/lib/types/dsh-adapter/presets.d.ts +18 -0
- package/lib/types/dsh-adapter/presets.d.ts.map +1 -1
- package/lib/types/dsh-adapter/presets.js +31 -0
- package/lib/types/dsh-adapter/providerWizard.d.ts +29 -1
- package/lib/types/dsh-adapter/providerWizard.d.ts.map +1 -1
- package/lib/types/dsh-adapter/providerWizard.js +89 -5
- package/lib/types/dsh-adapter/questions.d.ts +19 -2
- package/lib/types/dsh-adapter/questions.d.ts.map +1 -1
- package/lib/types/dsh-adapter/questions.js +53 -10
- package/lib/types/dsh-adapter/recap.d.ts +49 -0
- package/lib/types/dsh-adapter/recap.d.ts.map +1 -0
- package/lib/types/dsh-adapter/recap.js +121 -0
- package/lib/types/dsh-adapter/sessionTree.d.ts +314 -0
- package/lib/types/dsh-adapter/sessionTree.d.ts.map +1 -0
- package/lib/types/dsh-adapter/sessionTree.js +954 -0
- package/lib/types/dsh-adapter/shortcuts.d.ts +15 -44
- package/lib/types/dsh-adapter/shortcuts.d.ts.map +1 -1
- package/lib/types/dsh-adapter/shortcuts.js +28 -165
- package/lib/types/dsh-adapter/types.d.ts +1 -1
- package/lib/types/dsh-adapter/types.d.ts.map +1 -1
- package/lib/types/herdr.js +1 -1
- package/lib/types/i18n.d.ts +604 -176
- package/lib/types/i18n.d.ts.map +1 -1
- package/lib/types/i18n.js +206 -72
- package/lib/types/ink/log-update.d.ts.map +1 -1
- package/lib/types/ink/log-update.js +21 -0
- package/lib/types/migrationPrefs.d.ts +60 -0
- package/lib/types/migrationPrefs.d.ts.map +1 -0
- package/lib/types/migrationPrefs.js +97 -0
- package/lib/types/modelGroups.d.ts +72 -0
- package/lib/types/modelGroups.d.ts.map +1 -0
- package/lib/types/modelGroups.js +97 -0
- package/lib/types/modelRecents.d.ts +39 -0
- package/lib/types/modelRecents.d.ts.map +1 -0
- package/lib/types/modelRecents.js +97 -0
- package/lib/types/native-ts/yoga-layout/index.d.ts +11 -0
- package/lib/types/native-ts/yoga-layout/index.d.ts.map +1 -1
- package/lib/types/native-ts/yoga-layout/index.js +31 -1
- package/lib/types/oauth.d.ts +15 -0
- package/lib/types/oauth.d.ts.map +1 -0
- package/lib/types/oauth.js +14 -0
- package/lib/types/plugin-spec/registry.d.ts +1 -1
- package/lib/types/plugin-spec/registry.js +1 -1
- package/lib/types/reload.d.ts +109 -0
- package/lib/types/reload.d.ts.map +1 -0
- package/lib/types/reload.js +118 -0
- package/lib/types/screens/Chat.d.ts +3 -1
- package/lib/types/screens/Chat.d.ts.map +1 -1
- package/lib/types/screens/Chat.js +588 -64
- package/lib/types/screens/SessionTree.d.ts +27 -0
- package/lib/types/screens/SessionTree.d.ts.map +1 -0
- package/lib/types/screens/SessionTree.js +555 -0
- package/lib/types/screens/StatusLine.d.ts +0 -9
- package/lib/types/screens/StatusLine.d.ts.map +1 -1
- package/lib/types/screens/StatusLine.js +215 -52
- package/lib/types/screens/StatusMetrics.d.ts +35 -3
- package/lib/types/screens/StatusMetrics.d.ts.map +1 -1
- package/lib/types/screens/StatusMetrics.js +63 -8
- package/lib/types/screens/chatOverlay.d.ts +17 -1
- package/lib/types/screens/chatOverlay.d.ts.map +1 -1
- package/lib/types/screens/chatOverlay.js +3 -1
- package/lib/types/tips.d.ts.map +1 -1
- package/lib/types/tips.js +22 -4
- package/lib/types/update.d.ts +159 -0
- package/lib/types/update.d.ts.map +1 -1
- package/lib/types/update.js +496 -30
- package/lib/types/utils/debug.d.ts +1 -1
- package/lib/types/utils/debug.js +3 -3
- package/lib/types/utils/fileTarget.d.ts +46 -0
- package/lib/types/utils/fileTarget.d.ts.map +1 -0
- package/lib/types/utils/fileTarget.js +178 -0
- package/lib/types/utils/keymap.d.ts +134 -0
- package/lib/types/utils/keymap.d.ts.map +1 -0
- package/lib/types/utils/keymap.js +311 -0
- package/lib/types/utils/mentions.d.ts +25 -2
- package/lib/types/utils/mentions.d.ts.map +1 -1
- package/lib/types/utils/mentions.js +69 -10
- package/lib/types/utils/openExternal.d.ts +50 -0
- package/lib/types/utils/openExternal.d.ts.map +1 -0
- package/lib/types/utils/openExternal.js +170 -0
- package/node_modules/@deepseek-harness-tui/dsh-auth/LICENSE +21 -0
- package/node_modules/@deepseek-harness-tui/dsh-auth/README.md +140 -0
- package/node_modules/@deepseek-harness-tui/dsh-auth/cordis.patch.yml +11 -0
- package/node_modules/@deepseek-harness-tui/dsh-auth/dsh-plugin.json +44 -0
- package/node_modules/@deepseek-harness-tui/dsh-auth/lib/command.d.ts +14 -0
- package/node_modules/@deepseek-harness-tui/dsh-auth/lib/command.js +67 -0
- package/node_modules/@deepseek-harness-tui/dsh-auth/lib/credentials.d.ts +64 -0
- package/node_modules/@deepseek-harness-tui/dsh-auth/lib/credentials.js +170 -0
- package/node_modules/@deepseek-harness-tui/dsh-auth/lib/index.d.ts +79 -0
- package/node_modules/@deepseek-harness-tui/dsh-auth/lib/index.js +196 -0
- package/node_modules/@deepseek-harness-tui/dsh-auth/lib/interaction.d.ts +68 -0
- package/node_modules/@deepseek-harness-tui/dsh-auth/lib/interaction.js +193 -0
- package/node_modules/@deepseek-harness-tui/dsh-auth/lib/opener.d.ts +32 -0
- package/node_modules/@deepseek-harness-tui/dsh-auth/lib/opener.js +101 -0
- package/node_modules/@deepseek-harness-tui/dsh-auth/lib/profiles.d.ts +31 -0
- package/node_modules/@deepseek-harness-tui/dsh-auth/lib/profiles.js +68 -0
- package/node_modules/@deepseek-harness-tui/dsh-auth/lib/service.d.ts +69 -0
- package/node_modules/@deepseek-harness-tui/dsh-auth/lib/service.js +129 -0
- package/node_modules/@deepseek-harness-tui/dsh-auth/package.json +70 -0
- package/node_modules/@dsh-std/command/CHANGELOG.md +8 -8
- package/node_modules/@dsh-std/command/LICENSE +21 -21
- package/node_modules/@dsh-std/command/README.md +26 -26
- package/node_modules/@dsh-std/command/README.zh.md +28 -28
- package/node_modules/@dsh-std/command/lib/index.js.map +1 -1
- package/node_modules/@dsh-std/command/package.json +22 -22
- package/node_modules/@dsh-std/connection/CHANGELOG.md +8 -8
- package/node_modules/@dsh-std/connection/LICENSE +21 -21
- package/node_modules/@dsh-std/connection/README.md +42 -42
- package/node_modules/@dsh-std/connection/README.zh.md +42 -42
- package/node_modules/@dsh-std/connection/lib/index.js.map +1 -1
- package/node_modules/@dsh-std/connection/lib/memory.js.map +1 -1
- package/node_modules/@dsh-std/connection/lib/resolve-3X6ausgz.js.map +1 -1
- package/node_modules/@dsh-std/connection/package.json +20 -20
- package/node_modules/@dsh-std/core/CHANGELOG.md +8 -8
- package/node_modules/@dsh-std/core/LICENSE +21 -21
- package/node_modules/@dsh-std/core/README.md +13 -13
- package/node_modules/@dsh-std/core/README.zh.md +13 -13
- package/node_modules/@dsh-std/core/lib/index.js.map +1 -1
- package/node_modules/@dsh-std/core/package.json +21 -21
- package/node_modules/@dsh-std/manifest/CHANGELOG.md +9 -9
- package/node_modules/@dsh-std/manifest/LICENSE +21 -21
- package/node_modules/@dsh-std/manifest/README.md +5 -5
- package/node_modules/@dsh-std/manifest/README.zh.md +5 -5
- package/node_modules/@dsh-std/manifest/lib/index.js.map +1 -1
- package/node_modules/@dsh-std/manifest/package.json +20 -20
- package/node_modules/@dsh-std/manifest/schema/dsh-plugin-0.15.schema.json +144 -144
- package/node_modules/@dsh-std/messages/CHANGELOG.md +8 -8
- package/node_modules/@dsh-std/messages/LICENSE +21 -21
- package/node_modules/@dsh-std/messages/README.md +5 -5
- package/node_modules/@dsh-std/messages/README.zh.md +7 -7
- package/node_modules/@dsh-std/messages/lib/index.js.map +1 -1
- package/node_modules/@dsh-std/messages/package.json +20 -20
- package/node_modules/@dsh-std/messages/schema/message-observer-envelope-0.15.schema.json +54 -54
- package/node_modules/@dsh-std/presentation/CHANGELOG.md +9 -9
- package/node_modules/@dsh-std/presentation/LICENSE +21 -21
- package/node_modules/@dsh-std/presentation/README.md +27 -27
- package/node_modules/@dsh-std/presentation/README.zh.md +29 -29
- package/node_modules/@dsh-std/presentation/lib/callback.js.map +1 -1
- package/node_modules/@dsh-std/presentation/lib/index.js.map +1 -1
- package/node_modules/@dsh-std/presentation/package.json +25 -25
- package/node_modules/@dsh-std/storage/CHANGELOG.md +8 -8
- package/node_modules/@dsh-std/storage/LICENSE +21 -21
- package/node_modules/@dsh-std/storage/README.md +5 -5
- package/node_modules/@dsh-std/storage/README.zh.md +7 -7
- package/node_modules/@dsh-std/storage/lib/index.js.map +1 -1
- package/node_modules/@dsh-std/storage/package.json +20 -20
- package/node_modules/@dsh-std/storage/schema/local-storage-0.15.schema.json +56 -56
- package/package.json +17 -5
- package/presets/liangshen/.dsh-tui-managed.json +1 -1
- package/presets/liangshen/instruction-hint.mjs +8 -0
- package/vendor/dsh-std/packages/command/node_modules/@dsh-std/connection/CHANGELOG.md +8 -8
- package/vendor/dsh-std/packages/command/node_modules/@dsh-std/connection/LICENSE +21 -21
- package/vendor/dsh-std/packages/command/node_modules/@dsh-std/connection/README.md +42 -42
- package/vendor/dsh-std/packages/command/node_modules/@dsh-std/connection/README.zh.md +42 -42
- package/vendor/dsh-std/packages/command/node_modules/@dsh-std/connection/lib/index.js.map +1 -1
- package/vendor/dsh-std/packages/command/node_modules/@dsh-std/connection/lib/memory.js.map +1 -1
- package/vendor/dsh-std/packages/command/node_modules/@dsh-std/connection/lib/resolve-3X6ausgz.js.map +1 -1
- package/vendor/dsh-std/packages/command/node_modules/@dsh-std/connection/package.json +20 -20
- package/vendor/dsh-std/packages/command/node_modules/@dsh-std/core/CHANGELOG.md +8 -8
- package/vendor/dsh-std/packages/command/node_modules/@dsh-std/core/LICENSE +21 -21
- package/vendor/dsh-std/packages/command/node_modules/@dsh-std/core/README.md +13 -13
- package/vendor/dsh-std/packages/command/node_modules/@dsh-std/core/README.zh.md +13 -13
- package/vendor/dsh-std/packages/command/node_modules/@dsh-std/core/lib/index.js.map +1 -1
- package/vendor/dsh-std/packages/command/node_modules/@dsh-std/core/package.json +21 -21
- package/vendor/dsh-std/packages/command/node_modules/@dsh-std/manifest/CHANGELOG.md +9 -9
- package/vendor/dsh-std/packages/command/node_modules/@dsh-std/manifest/LICENSE +21 -21
- package/vendor/dsh-std/packages/command/node_modules/@dsh-std/manifest/README.md +5 -5
- package/vendor/dsh-std/packages/command/node_modules/@dsh-std/manifest/README.zh.md +5 -5
- package/vendor/dsh-std/packages/command/node_modules/@dsh-std/manifest/lib/index.js.map +1 -1
- package/vendor/dsh-std/packages/command/node_modules/@dsh-std/manifest/package.json +20 -20
- package/vendor/dsh-std/packages/command/node_modules/@dsh-std/manifest/schema/dsh-plugin-0.15.schema.json +144 -144
- package/vendor/dsh-std/packages/command/node_modules/@dsh-std/presentation/CHANGELOG.md +9 -9
- package/vendor/dsh-std/packages/command/node_modules/@dsh-std/presentation/LICENSE +21 -21
- package/vendor/dsh-std/packages/command/node_modules/@dsh-std/presentation/README.md +27 -27
- package/vendor/dsh-std/packages/command/node_modules/@dsh-std/presentation/README.zh.md +29 -29
- package/vendor/dsh-std/packages/command/node_modules/@dsh-std/presentation/lib/callback.js.map +1 -1
- package/vendor/dsh-std/packages/command/node_modules/@dsh-std/presentation/lib/index.js.map +1 -1
- package/vendor/dsh-std/packages/command/node_modules/@dsh-std/presentation/package.json +25 -25
- package/vendor/dsh-std/packages/connection/node_modules/@dsh-std/core/CHANGELOG.md +8 -8
- package/vendor/dsh-std/packages/connection/node_modules/@dsh-std/core/LICENSE +21 -21
- package/vendor/dsh-std/packages/connection/node_modules/@dsh-std/core/README.md +13 -13
- package/vendor/dsh-std/packages/connection/node_modules/@dsh-std/core/README.zh.md +13 -13
- package/vendor/dsh-std/packages/connection/node_modules/@dsh-std/core/lib/index.js.map +1 -1
- package/vendor/dsh-std/packages/connection/node_modules/@dsh-std/core/package.json +21 -21
- package/vendor/dsh-std/packages/manifest/node_modules/@dsh-std/core/CHANGELOG.md +8 -8
- package/vendor/dsh-std/packages/manifest/node_modules/@dsh-std/core/LICENSE +21 -21
- package/vendor/dsh-std/packages/manifest/node_modules/@dsh-std/core/README.md +13 -13
- package/vendor/dsh-std/packages/manifest/node_modules/@dsh-std/core/README.zh.md +13 -13
- package/vendor/dsh-std/packages/manifest/node_modules/@dsh-std/core/lib/index.js.map +1 -1
- package/vendor/dsh-std/packages/manifest/node_modules/@dsh-std/core/package.json +21 -21
- package/vendor/dsh-std/packages/messages/node_modules/@dsh-std/core/CHANGELOG.md +8 -8
- package/vendor/dsh-std/packages/messages/node_modules/@dsh-std/core/LICENSE +21 -21
- package/vendor/dsh-std/packages/messages/node_modules/@dsh-std/core/README.md +13 -13
- package/vendor/dsh-std/packages/messages/node_modules/@dsh-std/core/README.zh.md +13 -13
- package/vendor/dsh-std/packages/messages/node_modules/@dsh-std/core/lib/index.js.map +1 -1
- package/vendor/dsh-std/packages/messages/node_modules/@dsh-std/core/package.json +21 -21
- package/vendor/dsh-std/packages/presentation/node_modules/@dsh-std/connection/CHANGELOG.md +8 -8
- package/vendor/dsh-std/packages/presentation/node_modules/@dsh-std/connection/LICENSE +21 -21
- package/vendor/dsh-std/packages/presentation/node_modules/@dsh-std/connection/README.md +42 -42
- package/vendor/dsh-std/packages/presentation/node_modules/@dsh-std/connection/README.zh.md +42 -42
- package/vendor/dsh-std/packages/presentation/node_modules/@dsh-std/connection/lib/index.js.map +1 -1
- package/vendor/dsh-std/packages/presentation/node_modules/@dsh-std/connection/lib/memory.js.map +1 -1
- package/vendor/dsh-std/packages/presentation/node_modules/@dsh-std/connection/lib/resolve-3X6ausgz.js.map +1 -1
- package/vendor/dsh-std/packages/presentation/node_modules/@dsh-std/connection/package.json +20 -20
- package/vendor/dsh-std/packages/presentation/node_modules/@dsh-std/core/CHANGELOG.md +8 -8
- package/vendor/dsh-std/packages/presentation/node_modules/@dsh-std/core/LICENSE +21 -21
- package/vendor/dsh-std/packages/presentation/node_modules/@dsh-std/core/README.md +13 -13
- package/vendor/dsh-std/packages/presentation/node_modules/@dsh-std/core/README.zh.md +13 -13
- package/vendor/dsh-std/packages/presentation/node_modules/@dsh-std/core/lib/index.js.map +1 -1
- package/vendor/dsh-std/packages/presentation/node_modules/@dsh-std/core/package.json +21 -21
- package/vendor/dsh-std/packages/storage/node_modules/@dsh-std/core/CHANGELOG.md +8 -8
- package/vendor/dsh-std/packages/storage/node_modules/@dsh-std/core/LICENSE +21 -21
- package/vendor/dsh-std/packages/storage/node_modules/@dsh-std/core/README.md +13 -13
- package/vendor/dsh-std/packages/storage/node_modules/@dsh-std/core/README.zh.md +13 -13
- package/vendor/dsh-std/packages/storage/node_modules/@dsh-std/core/lib/index.js.map +1 -1
- package/vendor/dsh-std/packages/storage/node_modules/@dsh-std/core/package.json +21 -21
|
@@ -3,21 +3,25 @@ import { assembleContextFor, installModelSelection } from '@deepseek-ai/dsh-agen
|
|
|
3
3
|
import { isUserInvocable, renderSkillContent } from '@deepseek-ai/dsh-skill';
|
|
4
4
|
import { createUserMessage, isTokenDelta, MessageId, ReasoningEffortId, } from '@deepseek-ai/dsh-llm';
|
|
5
5
|
import { runSideQuestion, wrapSideQuestion } from './sideQuestion.js';
|
|
6
|
+
import { collectRecentActivity, parseRecapResponse, RECAP_RECENT_CHARS, wrapRecapPrompt } from './recap.js';
|
|
7
|
+
import { SESSION_COLOR_NAMES } from '../cc/sessionColors.js';
|
|
6
8
|
import { SessionId } from '@deepseek-ai/dsh-session';
|
|
7
9
|
import { renderContextSections, renderPrompt } from '@deepseek-ai/dsh-system-prompt';
|
|
8
10
|
import { loadBaselineInstructions } from '@deepseek-ai/dsh-agent-instructions';
|
|
9
11
|
import { extname, isAbsolute, join } from 'node:path';
|
|
10
12
|
import { completeCommands, HIDDEN_COMMAND_NAMES, isLocalCommandName, LOCAL_COMMANDS, parseCommandName } from '../commands.js';
|
|
11
13
|
import { clearResumeTarget, forgetSession, readResumeTarget, touchSession, writeResumeTarget } from '../sessionHistory.js';
|
|
12
|
-
import { appendSessionTitle, deleteSessionLog, ensureLegacySessionEventTypes, sessionsRoots } from './compat/index.js';
|
|
13
|
-
import {
|
|
14
|
+
import { appendSessionTitle, defaultMaxScanned, deleteSessionLog, ensureLegacySessionEventTypes, readSessionEventsFromFile, readSessionEventsFromLog, sessionsRoots } from './compat/index.js';
|
|
15
|
+
import { buildSessionTree, forkTarget, liveTailWindow, rewindTarget, turnUserText, } from './sessionTree.js';
|
|
16
|
+
import { resolveDshProfileName } from '../update.js';
|
|
17
|
+
import { listSummaries, locateSession, noteBranch, previewSession, readHeader, } from './sessions/index.js';
|
|
14
18
|
import { writeActivityFrames } from '../activityPrefs.js';
|
|
15
19
|
import { isPathLikeQuery, rankFileCandidates } from '../utils/fileSuggestions.js';
|
|
16
20
|
import { readEffortPref, writeEffortPref } from '../effortPrefs.js';
|
|
17
21
|
import { readModelPref, writeModelPref } from '../modelPrefs.js';
|
|
18
22
|
import { explicitModelRoute, recordedModelRoute, resolveModelRoute, validateModelRoute } from '../modelRoute.js';
|
|
19
23
|
import { readPresetPref, writePresetPref } from '../presetPrefs.js';
|
|
20
|
-
import { composePreset, resolvePersistedPreset, rosterOf, runningPresetOf, serviceForAgent } from './presets.js';
|
|
24
|
+
import { composePreset, resolvePersistedPreset, resolvePersistedRoute, rosterOf, runningPresetOf, serviceForAgent } from './presets.js';
|
|
21
25
|
import { isPresetName, PRESET_NAMES } from '../components/activityFrames.js';
|
|
22
26
|
import { existsSync, statSync, writeFileSync } from 'node:fs';
|
|
23
27
|
import { logForDebugging } from '../utils/debug.js';
|
|
@@ -866,6 +870,79 @@ export function createChannel(ctx, initialAgent, options) {
|
|
|
866
870
|
ctx.logger.warn('dsh-tui: tui/session-switched dispatch failed: %o', error);
|
|
867
871
|
}
|
|
868
872
|
};
|
|
873
|
+
/**
|
|
874
|
+
* Swap the live agent for a freshly created fork (rewindTo and the session
|
|
875
|
+
* tree's rewindToNode share this tail): reset every session-scoped
|
|
876
|
+
* projection, replay the fork's seed into a fresh transcript (tokens/
|
|
877
|
+
* spinner counters land back at the rewind point, matching the fork),
|
|
878
|
+
* rebind subscriptions to the new agent, and free the replaced handle.
|
|
879
|
+
* Returns the source session's id (for the session-switched notification).
|
|
880
|
+
*/
|
|
881
|
+
const adoptForkedAgent = (handle, seed, agentPreset, childId) => {
|
|
882
|
+
// Replay the forked history into a fresh transcript (tokens/spinner
|
|
883
|
+
// counters land back at the rewind point, matching the fork).
|
|
884
|
+
streaming = undefined;
|
|
885
|
+
reasoning = undefined;
|
|
886
|
+
// Stale sealed/thinking bookkeeping belongs to the OLD agent's rows;
|
|
887
|
+
// keep it out of the next turn's settle logs and revive cache.
|
|
888
|
+
sealedReasoning.length = 0;
|
|
889
|
+
lastReasoningRow = undefined;
|
|
890
|
+
toolCards.clear();
|
|
891
|
+
nextRowId = 0;
|
|
892
|
+
state.rows.length = 0;
|
|
893
|
+
// Goal/todo/title are session-scoped; the replay re-derives them for
|
|
894
|
+
// the session being entered (or leaves them empty).
|
|
895
|
+
state.todos = [];
|
|
896
|
+
// Queued-but-undelivered messages live in the OLD agent's inbox; the
|
|
897
|
+
// swap must drop their previews or they linger forever (unretirable —
|
|
898
|
+
// retire events are filtered to the new agent, unwithdrawable — the
|
|
899
|
+
// new inbox never heard of them).
|
|
900
|
+
state.pending = [];
|
|
901
|
+
state.goal = undefined;
|
|
902
|
+
state.sessionTitle = '';
|
|
903
|
+
state.sessionColor = '';
|
|
904
|
+
state.tokens = { input: 0, output: 0 };
|
|
905
|
+
state.responseChars = 0;
|
|
906
|
+
state.activeToolCount = 0;
|
|
907
|
+
state.lastUserText = '';
|
|
908
|
+
state.working = false;
|
|
909
|
+
state.spinnerMode = 'requesting';
|
|
910
|
+
state.status = handle.agent.status;
|
|
911
|
+
state.agentId = handle.agent.id;
|
|
912
|
+
state.agentPreset = agentPreset;
|
|
913
|
+
state.tps = undefined;
|
|
914
|
+
state.tpsSamples = [];
|
|
915
|
+
state.lastUsage = undefined;
|
|
916
|
+
state.workingActivity = undefined;
|
|
917
|
+
state.contextSegments = {
|
|
918
|
+
system: 0,
|
|
919
|
+
prompt: 0,
|
|
920
|
+
assistant: 0,
|
|
921
|
+
thinking: 0,
|
|
922
|
+
tools: 0,
|
|
923
|
+
};
|
|
924
|
+
replayEvents(seed);
|
|
925
|
+
settleStreaming();
|
|
926
|
+
// A seed ending mid-turn replays a turn/start that set working=true;
|
|
927
|
+
// the boot path resets this after replay — mirror it here so an idle
|
|
928
|
+
// rewound agent doesn't sit with a live spinner (a still-running
|
|
929
|
+
// agent re-asserts on its next event).
|
|
930
|
+
state.working = handle.agent.status === 'running';
|
|
931
|
+
// Rebind subscriptions to the new agent, then free the old one.
|
|
932
|
+
const oldHandle = currentHandle;
|
|
933
|
+
const sourceSessionId = String(agent.session.id);
|
|
934
|
+
agent = handle.agent;
|
|
935
|
+
currentHandle = handle;
|
|
936
|
+
bindAgent();
|
|
937
|
+
refreshCommandList();
|
|
938
|
+
void refreshLoadedContext();
|
|
939
|
+
void refreshSkillCommands();
|
|
940
|
+
// The forked session (rewind) becomes the most recently used.
|
|
941
|
+
touchSession(childId);
|
|
942
|
+
state.emit();
|
|
943
|
+
void oldHandle?.dispose().catch(() => { });
|
|
944
|
+
return sourceSessionId;
|
|
945
|
+
};
|
|
869
946
|
/** Monotonic token: only the latest `interruptAndDeliver` re-queues, so a
|
|
870
947
|
* second interrupt while the abort settles cannot double-deliver. */
|
|
871
948
|
let interruptSeq = 0;
|
|
@@ -1301,6 +1378,17 @@ export function createChannel(ctx, initialAgent, options) {
|
|
|
1301
1378
|
rows: [],
|
|
1302
1379
|
status: 'starting',
|
|
1303
1380
|
sessionTitle: '',
|
|
1381
|
+
sessionColor: '',
|
|
1382
|
+
get autoRecapOnOpen() {
|
|
1383
|
+
// Live read (not a boot snapshot): a /settings change applies on the
|
|
1384
|
+
// next session switch. No settings service → off (framework absent,
|
|
1385
|
+
// e.g. headless fixtures — nothing to configure and no llm route).
|
|
1386
|
+
const settings = ctx.get('settings');
|
|
1387
|
+
if (settings === undefined)
|
|
1388
|
+
return false;
|
|
1389
|
+
const ns = settings.describe({ redactSecrets: true }).find(entry => entry.ns === 'dsh-tui');
|
|
1390
|
+
return ns?.value?.recapOnOpen !== false;
|
|
1391
|
+
},
|
|
1304
1392
|
agentId: agent.id,
|
|
1305
1393
|
model: options.model,
|
|
1306
1394
|
provider: options.provider,
|
|
@@ -1325,10 +1413,16 @@ export function createChannel(ctx, initialAgent, options) {
|
|
|
1325
1413
|
modeIndex: 0,
|
|
1326
1414
|
workingActivity: undefined,
|
|
1327
1415
|
activityFrames: options.activityFrames,
|
|
1416
|
+
configuredProvider: options.configuredProvider,
|
|
1417
|
+
configuredModel: options.configuredModel,
|
|
1418
|
+
configuredPreset: options.configuredPreset,
|
|
1419
|
+
configuredActivityFrames: options.configuredActivityFrames,
|
|
1420
|
+
configuredLang: options.configuredLang,
|
|
1328
1421
|
diffLayout: options.diffLayout ?? 'auto',
|
|
1329
1422
|
thinkingFold: options.thinkingFold ?? 'preview',
|
|
1330
1423
|
toolBackground: normalizeToolBackground(options.toolBackground),
|
|
1331
1424
|
scrollGutter: normalizeScrollGutter(options.scrollGutter),
|
|
1425
|
+
promptSessionLabel: options.promptSessionLabel === true,
|
|
1332
1426
|
statusBar: normalizeStatusBar(options.statusBar),
|
|
1333
1427
|
whale: options.whale !== false,
|
|
1334
1428
|
minimal: options.minimal === true,
|
|
@@ -1388,6 +1482,18 @@ export function createChannel(ctx, initialAgent, options) {
|
|
|
1388
1482
|
})),
|
|
1389
1483
|
];
|
|
1390
1484
|
}
|
|
1485
|
+
if (path.length === 1 && path[0] === 'color') {
|
|
1486
|
+
return [
|
|
1487
|
+
{ name: 'status', description: 'Show the current session color', descriptionKey: 'sugg-status-desc' },
|
|
1488
|
+
{ name: 'reset', description: 'Clear the session color', descriptionKey: 'sugg-color-reset-desc' },
|
|
1489
|
+
...SESSION_COLOR_NAMES.map((name) => ({
|
|
1490
|
+
name,
|
|
1491
|
+
description: 'Session accent color',
|
|
1492
|
+
descriptionKey: 'sugg-color-name-desc',
|
|
1493
|
+
...(state.sessionColor === name ? { tag: 'current' } : {}),
|
|
1494
|
+
})),
|
|
1495
|
+
];
|
|
1496
|
+
}
|
|
1391
1497
|
if (path.length === 1 && path[0] === 'effort') {
|
|
1392
1498
|
warmEffortLevels();
|
|
1393
1499
|
return [
|
|
@@ -1774,67 +1880,9 @@ export function createChannel(ctx, initialAgent, options) {
|
|
|
1774
1880
|
catch (error) {
|
|
1775
1881
|
state.notify(t('rewind-attach-failed', { err: error instanceof Error ? error.message : String(error) }), { color: 'warning', timeoutMs: 8000 });
|
|
1776
1882
|
}
|
|
1777
|
-
//
|
|
1778
|
-
//
|
|
1779
|
-
|
|
1780
|
-
reasoning = undefined;
|
|
1781
|
-
// Stale sealed/thinking bookkeeping belongs to the OLD agent's rows;
|
|
1782
|
-
// keep it out of the next turn's settle logs and revive cache.
|
|
1783
|
-
sealedReasoning.length = 0;
|
|
1784
|
-
lastReasoningRow = undefined;
|
|
1785
|
-
toolCards.clear();
|
|
1786
|
-
nextRowId = 0;
|
|
1787
|
-
state.rows.length = 0;
|
|
1788
|
-
// Goal/todo/title are session-scoped; the replay re-derives them for
|
|
1789
|
-
// the session being entered (or leaves them empty).
|
|
1790
|
-
state.todos = [];
|
|
1791
|
-
// Queued-but-undelivered messages live in the OLD agent's inbox; the
|
|
1792
|
-
// swap must drop their previews or they linger forever (unretirable —
|
|
1793
|
-
// retire events are filtered to the new agent, unwithdrawable — the
|
|
1794
|
-
// new inbox never heard of them).
|
|
1795
|
-
state.pending = [];
|
|
1796
|
-
state.goal = undefined;
|
|
1797
|
-
state.sessionTitle = '';
|
|
1798
|
-
state.tokens = { input: 0, output: 0 };
|
|
1799
|
-
state.responseChars = 0;
|
|
1800
|
-
state.activeToolCount = 0;
|
|
1801
|
-
state.lastUserText = '';
|
|
1802
|
-
state.working = false;
|
|
1803
|
-
state.spinnerMode = 'requesting';
|
|
1804
|
-
state.status = handle.agent.status;
|
|
1805
|
-
state.agentId = handle.agent.id;
|
|
1806
|
-
state.agentPreset = rewindComposed.agentPreset;
|
|
1807
|
-
state.tps = undefined;
|
|
1808
|
-
state.tpsSamples = [];
|
|
1809
|
-
state.lastUsage = undefined;
|
|
1810
|
-
state.workingActivity = undefined;
|
|
1811
|
-
state.contextSegments = {
|
|
1812
|
-
system: 0,
|
|
1813
|
-
prompt: 0,
|
|
1814
|
-
assistant: 0,
|
|
1815
|
-
thinking: 0,
|
|
1816
|
-
tools: 0,
|
|
1817
|
-
};
|
|
1818
|
-
replayEvents(seed);
|
|
1819
|
-
settleStreaming();
|
|
1820
|
-
// A seed ending mid-turn replays a turn/start that set working=true;
|
|
1821
|
-
// the boot path resets this after replay — mirror it here so an idle
|
|
1822
|
-
// rewound agent doesn't sit with a live spinner (a still-running
|
|
1823
|
-
// agent re-asserts on its next event).
|
|
1824
|
-
state.working = handle.agent.status === 'running';
|
|
1825
|
-
// Rebind subscriptions to the new agent, then free the old one.
|
|
1826
|
-
const oldHandle = currentHandle;
|
|
1827
|
-
const sourceSessionId = String(agent.session.id);
|
|
1828
|
-
agent = handle.agent;
|
|
1829
|
-
currentHandle = handle;
|
|
1830
|
-
bindAgent();
|
|
1831
|
-
refreshCommandList();
|
|
1832
|
-
void refreshLoadedContext();
|
|
1833
|
-
void refreshSkillCommands();
|
|
1834
|
-
// The forked session (rewind) becomes the most recently used.
|
|
1835
|
-
touchSession(childId);
|
|
1836
|
-
state.emit();
|
|
1837
|
-
void oldHandle?.dispose().catch(() => { });
|
|
1883
|
+
// Swap the live agent for the fork (shared with rewindToNode): replay
|
|
1884
|
+
// the seed, rebind, and free the replaced handle.
|
|
1885
|
+
const sourceSessionId = adoptForkedAgent(handle, seed, rewindComposed.agentPreset, childId);
|
|
1838
1886
|
// Decision-event pair around the completed rewind: `tui/rewind-done`
|
|
1839
1887
|
// (the first non-empty string is toasted as the post-rewind summary,
|
|
1840
1888
|
// e.g. a plugin reporting restored files) and the generic
|
|
@@ -1874,6 +1922,685 @@ export function createChannel(ctx, initialAgent, options) {
|
|
|
1874
1922
|
notifySessionSwitched('rewind', String(childId), sourceSessionId);
|
|
1875
1923
|
return row.text;
|
|
1876
1924
|
},
|
|
1925
|
+
async buildSessionTree() {
|
|
1926
|
+
const persistence = ctx.get('sessionPersistence');
|
|
1927
|
+
if (!persistence) {
|
|
1928
|
+
state.notify(t('tree-unavailable'), { color: 'error' });
|
|
1929
|
+
return null;
|
|
1930
|
+
}
|
|
1931
|
+
// Pin the live session snapshot NOW: every await below (list/inspect)
|
|
1932
|
+
// is a window in which a fire-and-forget switch (/new, /resume,
|
|
1933
|
+
// /model) can swap `agent`. Reading agent.session piecemeal would
|
|
1934
|
+
// stitch the NEW session's events under the OLD session's id — a
|
|
1935
|
+
// confirm would then rewind from the wrong persisted log. Everything
|
|
1936
|
+
// below reads this snapshot, and the result is discarded if the live
|
|
1937
|
+
// session moved on before the build finished.
|
|
1938
|
+
const liveSession = agent.session;
|
|
1939
|
+
const currentId = String(liveSession.id);
|
|
1940
|
+
// Same enumerate as the /resume listing (snapshots when the backend
|
|
1941
|
+
// offers revisions, plain list otherwise), each header narrowed through
|
|
1942
|
+
// the sessions reader — one malformed header costs that session its
|
|
1943
|
+
// metadata, never the whole tree. `raw` stays the backend's own header
|
|
1944
|
+
// object for locate() below.
|
|
1945
|
+
let listed = [];
|
|
1946
|
+
try {
|
|
1947
|
+
if (typeof persistence.listSnapshots === 'function') {
|
|
1948
|
+
const snapshots = await persistence.listSnapshots();
|
|
1949
|
+
listed = snapshots.flatMap(snapshot => {
|
|
1950
|
+
const raw = snapshot?.header;
|
|
1951
|
+
const header = readHeader(raw);
|
|
1952
|
+
return header === undefined ? [] : [{ header, raw }];
|
|
1953
|
+
});
|
|
1954
|
+
}
|
|
1955
|
+
else if (typeof persistence.list === 'function') {
|
|
1956
|
+
const headers = await persistence.list();
|
|
1957
|
+
listed = headers.flatMap(raw => {
|
|
1958
|
+
const header = readHeader(raw);
|
|
1959
|
+
return header === undefined ? [] : [{ header, raw }];
|
|
1960
|
+
});
|
|
1961
|
+
}
|
|
1962
|
+
}
|
|
1963
|
+
catch {
|
|
1964
|
+
// A listing failure degrades the tree to the live session only.
|
|
1965
|
+
}
|
|
1966
|
+
// Same cwd scoping as /resume (Claude Code's project dimension): forks
|
|
1967
|
+
// inherit cwd, so the family never crosses projects — and the match is
|
|
1968
|
+
// the project-aware one /resume uses, so a pre-upgrade subdirectory
|
|
1969
|
+
// path, Windows separators, or a case variant on one header cannot
|
|
1970
|
+
// quietly amputate the ancestors and siblings it records. Subagent
|
|
1971
|
+
// child sessions carry parentSession too, but they are delegation
|
|
1972
|
+
// artifacts, not rewind branches — exclude them from the family.
|
|
1973
|
+
const local = listed.filter(entry => sessionCwdMatches(state.cwd, entry.header.cwd ?? '') &&
|
|
1974
|
+
entry.header.origin !== 'subagent' &&
|
|
1975
|
+
(entry.header.delegationDepth ?? 0) === 0);
|
|
1976
|
+
const headerById = new Map(local.map(entry => [entry.header.id, entry]));
|
|
1977
|
+
// The live session's header may not be materialized in list() yet
|
|
1978
|
+
// (the jsonl backend writes on first append) — overlay the in-memory
|
|
1979
|
+
// header so the ancestor walk below still finds a fresh fork's parent.
|
|
1980
|
+
const liveMeta = liveSession.header;
|
|
1981
|
+
if (!headerById.has(currentId) && liveMeta !== undefined) {
|
|
1982
|
+
headerById.set(currentId, { header: readHeader(liveMeta) ?? { id: currentId, cwd: undefined, createdAt: undefined, parentSession: undefined, origin: undefined, delegationDepth: undefined, seedLength: undefined, agentPreset: undefined }, raw: liveMeta });
|
|
1983
|
+
}
|
|
1984
|
+
// Family = the live session's ancestor chain PLUS every descendant of
|
|
1985
|
+
// its topmost known ancestor (siblings and cousins included).
|
|
1986
|
+
const childrenByParent = new Map();
|
|
1987
|
+
for (const entry of local) {
|
|
1988
|
+
if (entry.header.parentSession === undefined)
|
|
1989
|
+
continue;
|
|
1990
|
+
const list = childrenByParent.get(entry.header.parentSession);
|
|
1991
|
+
if (list === undefined)
|
|
1992
|
+
childrenByParent.set(entry.header.parentSession, [entry.header.id]);
|
|
1993
|
+
else
|
|
1994
|
+
list.push(entry.header.id);
|
|
1995
|
+
}
|
|
1996
|
+
const ancestorIds = [];
|
|
1997
|
+
{
|
|
1998
|
+
const visited = new Set([currentId]);
|
|
1999
|
+
let cursor = headerById.get(currentId);
|
|
2000
|
+
while (cursor?.header.parentSession !== undefined) {
|
|
2001
|
+
const parentId = cursor.header.parentSession;
|
|
2002
|
+
if (visited.has(parentId))
|
|
2003
|
+
break;
|
|
2004
|
+
visited.add(parentId);
|
|
2005
|
+
if (!headerById.has(parentId))
|
|
2006
|
+
break;
|
|
2007
|
+
ancestorIds.push(parentId);
|
|
2008
|
+
cursor = headerById.get(parentId);
|
|
2009
|
+
}
|
|
2010
|
+
}
|
|
2011
|
+
// BFS from the topmost ancestor. The scan must NOT be gated by family
|
|
2012
|
+
// membership: ancestor-chain nodes are already in the family, and
|
|
2013
|
+
// skipping them here would never enumerate their other children —
|
|
2014
|
+
// siblings/cousins forking off a MIDDLE ancestor would be lost.
|
|
2015
|
+
const family = new Set([currentId, ...ancestorIds]);
|
|
2016
|
+
{
|
|
2017
|
+
const scanned = new Set();
|
|
2018
|
+
const queue = [ancestorIds.at(-1) ?? currentId];
|
|
2019
|
+
while (queue.length > 0) {
|
|
2020
|
+
const id = queue.shift();
|
|
2021
|
+
if (scanned.has(id))
|
|
2022
|
+
continue;
|
|
2023
|
+
scanned.add(id);
|
|
2024
|
+
family.add(id);
|
|
2025
|
+
for (const child of childrenByParent.get(id) ?? []) {
|
|
2026
|
+
queue.push(child);
|
|
2027
|
+
}
|
|
2028
|
+
}
|
|
2029
|
+
}
|
|
2030
|
+
// Processing order is TOPOLOGICAL (a parent before its children): the
|
|
2031
|
+
// coverage bookkeeping below — which seq range each chain already
|
|
2032
|
+
// shows — feeds the next read's inherited-prefix skip, so a parent
|
|
2033
|
+
// must be read before its forks. Within each sibling group the live
|
|
2034
|
+
// chain wins, then newest first (the same priority the read budget
|
|
2035
|
+
// always had).
|
|
2036
|
+
const ancestorSet = new Set(ancestorIds);
|
|
2037
|
+
const priorityOf = (a, b) => {
|
|
2038
|
+
const aChain = a === currentId || ancestorSet.has(a);
|
|
2039
|
+
const bChain = b === currentId || ancestorSet.has(b);
|
|
2040
|
+
if (aChain !== bChain)
|
|
2041
|
+
return aChain ? -1 : 1;
|
|
2042
|
+
return (headerById.get(b)?.header.createdAt ?? 0) - (headerById.get(a)?.header.createdAt ?? 0);
|
|
2043
|
+
};
|
|
2044
|
+
const kidsOf = new Map();
|
|
2045
|
+
const familyRoots = [];
|
|
2046
|
+
for (const id of family) {
|
|
2047
|
+
const parentId = headerById.get(id)?.header.parentSession;
|
|
2048
|
+
if (parentId !== undefined && parentId !== id && family.has(parentId)) {
|
|
2049
|
+
const list = kidsOf.get(parentId);
|
|
2050
|
+
if (list === undefined)
|
|
2051
|
+
kidsOf.set(parentId, [id]);
|
|
2052
|
+
else
|
|
2053
|
+
list.push(id);
|
|
2054
|
+
}
|
|
2055
|
+
else {
|
|
2056
|
+
familyRoots.push(id);
|
|
2057
|
+
}
|
|
2058
|
+
}
|
|
2059
|
+
familyRoots.sort(priorityOf);
|
|
2060
|
+
for (const list of kidsOf.values())
|
|
2061
|
+
list.sort(priorityOf);
|
|
2062
|
+
const ordered = [];
|
|
2063
|
+
{
|
|
2064
|
+
const seen = new Set();
|
|
2065
|
+
const stack = [...familyRoots].reverse();
|
|
2066
|
+
while (stack.length > 0) {
|
|
2067
|
+
const id = stack.pop();
|
|
2068
|
+
if (seen.has(id))
|
|
2069
|
+
continue;
|
|
2070
|
+
seen.add(id);
|
|
2071
|
+
ordered.push(id);
|
|
2072
|
+
const kids = kidsOf.get(id);
|
|
2073
|
+
if (kids !== undefined) {
|
|
2074
|
+
for (let i = kids.length - 1; i >= 0; i--)
|
|
2075
|
+
stack.push(kids[i]);
|
|
2076
|
+
}
|
|
2077
|
+
}
|
|
2078
|
+
// Cycle-broken leftovers (corrupt parent headers) — never drop one.
|
|
2079
|
+
for (const id of [...family].sort(priorityOf)) {
|
|
2080
|
+
if (!seen.has(id))
|
|
2081
|
+
ordered.push(id);
|
|
2082
|
+
}
|
|
2083
|
+
}
|
|
2084
|
+
// Caps: the ancestor chain + live session ALWAYS stay selected — the
|
|
2085
|
+
// structural invariant (the live branch must reach the family root)
|
|
2086
|
+
// outranks the session cap, which therefore evicts only non-ancestors.
|
|
2087
|
+
// The event budget bounds the READ cost too: non-live logs decode
|
|
2088
|
+
// lazily and stop at the remaining budget (see below), the live
|
|
2089
|
+
// session keeps only its tail.
|
|
2090
|
+
const MAX_TREE_SESSIONS = 24;
|
|
2091
|
+
const MAX_TREE_EVENTS = 200_000;
|
|
2092
|
+
// The event budget alone does NOT bound read cost: skipped envelopes
|
|
2093
|
+
// (ignorable frames, headers) are paid for but never collected, so a
|
|
2094
|
+
// noisy log can return ZERO events and leave the next log a full scan
|
|
2095
|
+
// allowance — 23 logs × ~800k envelopes would block the TUI for
|
|
2096
|
+
// seconds. scanBudget caps the TOTAL envelopes inspected across all
|
|
2097
|
+
// logs (the reader reports its real scanned count); per-log caps
|
|
2098
|
+
// derive from the event budget as before, and the smaller of the two
|
|
2099
|
+
// applies, so one flood cannot starve every later sibling either.
|
|
2100
|
+
const MAX_TREE_SCANNED = defaultMaxScanned(MAX_TREE_EVENTS);
|
|
2101
|
+
let scanBudget = MAX_TREE_SCANNED;
|
|
2102
|
+
const selected = new Set();
|
|
2103
|
+
let slots = MAX_TREE_SESSIONS;
|
|
2104
|
+
for (const id of ordered) {
|
|
2105
|
+
const chain = id === currentId || ancestorSet.has(id);
|
|
2106
|
+
if (chain || slots > 0) {
|
|
2107
|
+
selected.add(id);
|
|
2108
|
+
if (!chain)
|
|
2109
|
+
slots -= 1;
|
|
2110
|
+
}
|
|
2111
|
+
}
|
|
2112
|
+
// The live session's events come from memory (its header may not be
|
|
2113
|
+
// materialized yet — the jsonl backend writes on first append).
|
|
2114
|
+
const liveHeader = headerById.get(currentId);
|
|
2115
|
+
const familySessions = [];
|
|
2116
|
+
let truncated = selected.size < family.size;
|
|
2117
|
+
let eventBudget = 0;
|
|
2118
|
+
// Coverage bookkeeping: coveredThrough(S) = the highest K such that
|
|
2119
|
+
// [0..K] is already displayed by S's chain or an ancestor's. A fork's
|
|
2120
|
+
// inherited seed prefix duplicates that range, so non-live reads SKIP
|
|
2121
|
+
// it (the reader's skipBelowSeq): the prefix still costs scan budget
|
|
2122
|
+
// (its bytes are read and parsed) but NOT the event budget — a fork
|
|
2123
|
+
// of a huge parent pays only for its OWN events, so two small forks
|
|
2124
|
+
// of a 70k-event parent both stay visible. Unreadable/unloaded
|
|
2125
|
+
// sessions are transparent: they claim nothing beyond what their own
|
|
2126
|
+
// ancestors covered, so a fork of a dead branch dedups against the
|
|
2127
|
+
// grandparent instead of hiding its self-contained history.
|
|
2128
|
+
const coveredThrough = new Map();
|
|
2129
|
+
for (const id of ordered) {
|
|
2130
|
+
if (!selected.has(id))
|
|
2131
|
+
continue;
|
|
2132
|
+
const entry = headerById.get(id);
|
|
2133
|
+
if (id === currentId) {
|
|
2134
|
+
const liveParentId = liveHeader?.header.parentSession ?? liveMeta?.parentSession;
|
|
2135
|
+
const liveParent = liveParentId !== undefined ? String(liveParentId) : undefined;
|
|
2136
|
+
const parentCovered = liveParent !== undefined
|
|
2137
|
+
? (coveredThrough.get(liveParent) ?? -1)
|
|
2138
|
+
: -1;
|
|
2139
|
+
const liveEvents = liveSession.events;
|
|
2140
|
+
const remaining = Math.max(0, MAX_TREE_EVENTS - eventBudget);
|
|
2141
|
+
// The live session's in-memory log is SELF-CONTAINED: a fork's
|
|
2142
|
+
// events still carry the inherited seed prefix, which the parent's
|
|
2143
|
+
// chain already displays (and already charged to the budget).
|
|
2144
|
+
// Skipping it exactly like the non-live reads do keeps a live fork
|
|
2145
|
+
// of a huge parent from spending the whole family budget on
|
|
2146
|
+
// duplicated history and evicting its own siblings.
|
|
2147
|
+
const liveSeed = liveHeader?.header.seedLength ?? liveMeta?.seedLength;
|
|
2148
|
+
const skipBelow = liveParent !== undefined && liveSeed !== undefined
|
|
2149
|
+
? Math.min(liveSeed, parentCovered + 1)
|
|
2150
|
+
: 0;
|
|
2151
|
+
const own = skipBelow > 0 ? liveEvents.filter(event => event.seq >= skipBelow) : liveEvents;
|
|
2152
|
+
// A live session larger than the remaining budget keeps its TAIL,
|
|
2153
|
+
// aligned to whole turns (sessionTree.liveTailWindow): leftover
|
|
2154
|
+
// entries of a turn whose turn/start was cut away render as
|
|
2155
|
+
// selectable rows that can never rewind; a window holding no
|
|
2156
|
+
// turn/start at all (one oversized LAST turn spans the budget)
|
|
2157
|
+
// retries over the earlier complete turns instead of blacking the
|
|
2158
|
+
// session out. Rewind itself never reads this copy (rewindToNode
|
|
2159
|
+
// forks the real session), so the slice only narrows what the tree
|
|
2160
|
+
// can display.
|
|
2161
|
+
const events = liveTailWindow(own, remaining);
|
|
2162
|
+
// Charge the KEPT tail, not the in-memory length: extraction only
|
|
2163
|
+
// ever touches `events`, and charging the full log would black out
|
|
2164
|
+
// every other family member's budget behind a discarded prefix.
|
|
2165
|
+
eventBudget += events.length;
|
|
2166
|
+
if (events.length !== own.length)
|
|
2167
|
+
truncated = true;
|
|
2168
|
+
familySessions.push({
|
|
2169
|
+
id,
|
|
2170
|
+
createdAt: liveHeader?.header.createdAt ?? liveMeta?.createdAt ?? Date.now(),
|
|
2171
|
+
...(liveParent !== undefined ? { parentSession: liveParent } : {}),
|
|
2172
|
+
...(liveHeader?.header.seedLength !== undefined || liveMeta?.seedLength !== undefined
|
|
2173
|
+
? { seedLength: liveHeader?.header.seedLength ?? liveMeta.seedLength }
|
|
2174
|
+
: {}),
|
|
2175
|
+
events,
|
|
2176
|
+
live: true,
|
|
2177
|
+
// The in-memory log always reaches the tip (liveTailWindow trims
|
|
2178
|
+
// the head only), so the adopt/warning UX facts are derivable.
|
|
2179
|
+
tailComplete: true,
|
|
2180
|
+
});
|
|
2181
|
+
// A kept tail cut off the front connects to nothing — coverage
|
|
2182
|
+
// stays at the parent's (a fork of the live session re-reads the
|
|
2183
|
+
// hidden prefix from its own log).
|
|
2184
|
+
const firstKept = events.length > 0 ? events[0].seq : Number.POSITIVE_INFINITY;
|
|
2185
|
+
const lastKept = events.length > 0 ? events[events.length - 1].seq : -1;
|
|
2186
|
+
coveredThrough.set(id, firstKept <= parentCovered + 1 ? Math.max(parentCovered, lastKept) : parentCovered);
|
|
2187
|
+
continue;
|
|
2188
|
+
}
|
|
2189
|
+
const header = entry?.header;
|
|
2190
|
+
const parentId = header?.parentSession;
|
|
2191
|
+
const parentCovered = parentId !== undefined ? (coveredThrough.get(parentId) ?? -1) : -1;
|
|
2192
|
+
// Never skip past the seed prefix: events beyond it are this
|
|
2193
|
+
// session's OWN — no ancestor can show them. A parent that was never
|
|
2194
|
+
// read (evicted, or outside the family) covers nothing (skip 0).
|
|
2195
|
+
const skipBelow = parentId !== undefined && header?.seedLength !== undefined
|
|
2196
|
+
? Math.min(header.seedLength, parentCovered + 1)
|
|
2197
|
+
: 0;
|
|
2198
|
+
const facts = {
|
|
2199
|
+
id,
|
|
2200
|
+
createdAt: header?.createdAt ?? 0,
|
|
2201
|
+
...(parentId !== undefined ? { parentSession: parentId } : {}),
|
|
2202
|
+
...(header?.seedLength !== undefined ? { seedLength: header.seedLength } : {}),
|
|
2203
|
+
};
|
|
2204
|
+
if (eventBudget >= MAX_TREE_EVENTS || scanBudget <= 0) {
|
|
2205
|
+
// Budget spent: keep the STRUCTURE — the session degrades to an
|
|
2206
|
+
// unloaded placeholder so its branch (and any ancestor chain
|
|
2207
|
+
// through it) stays visible instead of vanishing from the tree.
|
|
2208
|
+
truncated = true;
|
|
2209
|
+
familySessions.push({ ...facts, events: [], live: false, unloaded: true });
|
|
2210
|
+
coveredThrough.set(id, parentCovered);
|
|
2211
|
+
continue;
|
|
2212
|
+
}
|
|
2213
|
+
// Read-only, tolerant, bounded: the compat reader decodes frames
|
|
2214
|
+
// lazily and stops at the remaining event budget. Browsing the tree
|
|
2215
|
+
// must never REWRITE history logs (the ignorable-marking repair
|
|
2216
|
+
// stays on the explicit resume/rewind path), and the strict backend
|
|
2217
|
+
// inspect would both reject third-party event types wholesale and
|
|
2218
|
+
// parse chunk-heavy logs whole. Header facts come from list().
|
|
2219
|
+
const remaining = MAX_TREE_EVENTS - eventBudget;
|
|
2220
|
+
// Source precedence, all read-only:
|
|
2221
|
+
// 1. persistence.locate — the backend's OWN artifact resolution is
|
|
2222
|
+
// authoritative (custom root, workspace-key scheme). When it
|
|
2223
|
+
// names a path, ONLY that file is read: falling back to a
|
|
2224
|
+
// same-id copy under the stock root could surface a STALE log
|
|
2225
|
+
// from another backend configuration. A locate miss or an ABSENT
|
|
2226
|
+
// file falls through to inspect, never to the stock scan.
|
|
2227
|
+
// 2. Stock root scan — only for backends WITHOUT locate (fakes,
|
|
2228
|
+
// older custom implementations).
|
|
2229
|
+
// 3. inspect — the backend's strict read (non-file backends), with
|
|
2230
|
+
// the same budget enforced on what we keep — and ONLY when the
|
|
2231
|
+
// file read found NOTHING (undefined). A read that failed on a
|
|
2232
|
+
// safety cap or corruption (failed) must never escalate here:
|
|
2233
|
+
// inspect parses the WHOLE log up front, so falling through
|
|
2234
|
+
// would re-read unboundedly exactly the logs the caps exist to
|
|
2235
|
+
// bound (64 MiB frames, decode bombs) — degrade to a placeholder
|
|
2236
|
+
// instead.
|
|
2237
|
+
let events;
|
|
2238
|
+
let complete = true;
|
|
2239
|
+
let failed = false;
|
|
2240
|
+
// First seq the chosen source actually covers: the file readers start
|
|
2241
|
+
// at the inherited-prefix skip, inspect always hands the whole log.
|
|
2242
|
+
let readFrom = 0;
|
|
2243
|
+
// Per-log scan allowance: the usual 4×-of-remaining derivation,
|
|
2244
|
+
// clamped to what the tree-level scan budget still has.
|
|
2245
|
+
const scanAllowance = Math.min(defaultMaxScanned(remaining), scanBudget);
|
|
2246
|
+
const locate = persistence.locate;
|
|
2247
|
+
const hasLocate = typeof locate === 'function';
|
|
2248
|
+
if (hasLocate && entry !== undefined) {
|
|
2249
|
+
let locatedPath;
|
|
2250
|
+
try {
|
|
2251
|
+
const location = locate.call(persistence, entry.raw);
|
|
2252
|
+
// Only the jsonl kind enters the compat file layer — a foreign
|
|
2253
|
+
// kind's artifact is the backend's own format (inspect below).
|
|
2254
|
+
if (location !== null && typeof location === 'object') {
|
|
2255
|
+
const record = location;
|
|
2256
|
+
if (record.kind === 'jsonl' && typeof record.path === 'string') {
|
|
2257
|
+
locatedPath = record.path;
|
|
2258
|
+
}
|
|
2259
|
+
}
|
|
2260
|
+
}
|
|
2261
|
+
catch {
|
|
2262
|
+
// Best effort — a locate hiccup falls through to inspect.
|
|
2263
|
+
}
|
|
2264
|
+
if (locatedPath !== undefined) {
|
|
2265
|
+
const viaPath = readSessionEventsFromFile(locatedPath, remaining, scanAllowance, skipBelow);
|
|
2266
|
+
if (viaPath !== undefined) {
|
|
2267
|
+
scanBudget -= viaPath.scanned;
|
|
2268
|
+
if (viaPath.failed === true)
|
|
2269
|
+
failed = true;
|
|
2270
|
+
else {
|
|
2271
|
+
events = viaPath.events;
|
|
2272
|
+
complete = viaPath.complete;
|
|
2273
|
+
readFrom = skipBelow;
|
|
2274
|
+
}
|
|
2275
|
+
}
|
|
2276
|
+
}
|
|
2277
|
+
}
|
|
2278
|
+
else if (!hasLocate) {
|
|
2279
|
+
const read = readSessionEventsFromLog(id, remaining, scanAllowance, skipBelow);
|
|
2280
|
+
if (read !== undefined) {
|
|
2281
|
+
scanBudget -= read.scanned;
|
|
2282
|
+
if (read.failed === true)
|
|
2283
|
+
failed = true;
|
|
2284
|
+
else {
|
|
2285
|
+
events = read.events;
|
|
2286
|
+
complete = read.complete;
|
|
2287
|
+
readFrom = skipBelow;
|
|
2288
|
+
}
|
|
2289
|
+
}
|
|
2290
|
+
}
|
|
2291
|
+
if (!failed && events === undefined && typeof persistence.inspect === 'function') {
|
|
2292
|
+
try {
|
|
2293
|
+
const inspection = await persistence.inspect(SessionId(id));
|
|
2294
|
+
// inspect parses the WHOLE log up front: charge the full length
|
|
2295
|
+
// to the scan budget (may overdraw; the next iterations skip).
|
|
2296
|
+
scanBudget -= inspection.events.length;
|
|
2297
|
+
// Non-file backends hand back the self-contained log from seq 0:
|
|
2298
|
+
// the inherited-prefix skip the file readers got must apply here
|
|
2299
|
+
// too, or a long prefix would fill the slice and the branch's OWN
|
|
2300
|
+
// events — the only ones nobody else displays — would be cut.
|
|
2301
|
+
const all = skipBelow > 0 ? inspection.events.filter(event => event.seq >= skipBelow) : inspection.events;
|
|
2302
|
+
readFrom = skipBelow;
|
|
2303
|
+
events = all;
|
|
2304
|
+
if (events.length > remaining) {
|
|
2305
|
+
events = events.slice(0, remaining);
|
|
2306
|
+
complete = false;
|
|
2307
|
+
}
|
|
2308
|
+
}
|
|
2309
|
+
catch {
|
|
2310
|
+
events = undefined;
|
|
2311
|
+
}
|
|
2312
|
+
}
|
|
2313
|
+
if (failed || events === undefined) {
|
|
2314
|
+
// An unreadable log keeps the branch structure, no entries — and
|
|
2315
|
+
// stays transparent for coverage, so a fork of this branch dedups
|
|
2316
|
+
// against the grandparent instead of hiding its own history.
|
|
2317
|
+
familySessions.push({ ...facts, events: [], live: false, unreadable: true });
|
|
2318
|
+
coveredThrough.set(id, parentCovered);
|
|
2319
|
+
continue;
|
|
2320
|
+
}
|
|
2321
|
+
eventBudget += events.length;
|
|
2322
|
+
if (!complete)
|
|
2323
|
+
truncated = true;
|
|
2324
|
+
// tailComplete gates the branch-adopt target and the drop-turn
|
|
2325
|
+
// warning: a budget-sliced read lost the tail, and a tip computed
|
|
2326
|
+
// from it would fork mid-branch while claiming to keep everything.
|
|
2327
|
+
familySessions.push({ ...facts, events, live: false, ...(complete ? { tailComplete: true } : {}) });
|
|
2328
|
+
const lastRead = events.length > 0 ? events[events.length - 1].seq : -1;
|
|
2329
|
+
coveredThrough.set(id, readFrom <= parentCovered + 1 ? Math.max(parentCovered, lastRead) : parentCovered);
|
|
2330
|
+
}
|
|
2331
|
+
// A session swap mid-build invalidates the whole assembly (it mixes
|
|
2332
|
+
// the snapshot's lineage with headers listed for the OLD cwd state):
|
|
2333
|
+
// drop it silently — the reopened tree rebuilds on the new session.
|
|
2334
|
+
if (agent.session !== liveSession)
|
|
2335
|
+
return null;
|
|
2336
|
+
return buildSessionTree(familySessions, currentId, truncated);
|
|
2337
|
+
},
|
|
2338
|
+
async rewindToNode(sessionId, seq, mode = 'rewind') {
|
|
2339
|
+
const agents = ctx.get('agents');
|
|
2340
|
+
if (!agents) {
|
|
2341
|
+
state.notify(t('rewind-unavailable'), { color: 'error' });
|
|
2342
|
+
return null;
|
|
2343
|
+
}
|
|
2344
|
+
// Pin the entry-time session: the awaits below (log load, preset
|
|
2345
|
+
// compose, agent create) are windows in which a queued switch
|
|
2346
|
+
// (/new, /resume, /model) can swap `agent` — the mutation queue only
|
|
2347
|
+
// serializes the REPLACING entries, so the boundary and restored text
|
|
2348
|
+
// derive from THIS session's log and any swap along the way aborts
|
|
2349
|
+
// the rewind (forking or disposing whatever agent happens to be
|
|
2350
|
+
// current at the end would rewind the wrong session).
|
|
2351
|
+
const entrySession = agent.session;
|
|
2352
|
+
const currentId = String(entrySession.id);
|
|
2353
|
+
const childId = SessionId(randomUUID());
|
|
2354
|
+
// Source events: the live session from memory; any other family member
|
|
2355
|
+
// from its durable log (legacy event types registered first — the same
|
|
2356
|
+
// in-process compat seam as resumeTo, since load validates known types).
|
|
2357
|
+
let sourceEvents;
|
|
2358
|
+
let sourceCwd = state.cwd;
|
|
2359
|
+
let forkFromLive = true;
|
|
2360
|
+
if (sessionId === currentId) {
|
|
2361
|
+
sourceEvents = entrySession.events;
|
|
2362
|
+
}
|
|
2363
|
+
else {
|
|
2364
|
+
forkFromLive = false;
|
|
2365
|
+
const persistence = ctx.get('sessionPersistence');
|
|
2366
|
+
if (!persistence || typeof persistence.load !== 'function') {
|
|
2367
|
+
state.notify(t('rewind-no-persistence'), { color: 'error' });
|
|
2368
|
+
return null;
|
|
2369
|
+
}
|
|
2370
|
+
try {
|
|
2371
|
+
ensureLegacySessionEventTypes();
|
|
2372
|
+
const loaded = await persistence.load(SessionId(sessionId));
|
|
2373
|
+
sourceEvents = loaded.events;
|
|
2374
|
+
sourceCwd = loaded.meta.cwd ?? state.cwd;
|
|
2375
|
+
}
|
|
2376
|
+
catch (error) {
|
|
2377
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2378
|
+
state.notify(t('rewind-load-failed', { err: message }), { color: 'error' });
|
|
2379
|
+
return null;
|
|
2380
|
+
}
|
|
2381
|
+
}
|
|
2382
|
+
// DSH event order is `turn/start → user/message → … → turn/end`, and a
|
|
2383
|
+
// fork seed must not end inside an open turn. pi's navigateTree
|
|
2384
|
+
// semantics mapped onto that constraint (sessionTree.rewindTarget): a
|
|
2385
|
+
// USER message drops its turn — the boundary sits just before the
|
|
2386
|
+
// turn/start and the prompt comes back into the input for re-editing;
|
|
2387
|
+
// any OTHER entry keeps through its enclosing STEP — a mid-turn cut at
|
|
2388
|
+
// the step/end with the turn closed synthetically (DSH agentic turns
|
|
2389
|
+
// span thousands of events, so turn-granular keeping would barely move
|
|
2390
|
+
// the visible history). Fork mode (pi's /fork) instead KEEPS the
|
|
2391
|
+
// picked entry: a user message cuts right after itself (the turn's
|
|
2392
|
+
// reply drops) and never returns text to the input.
|
|
2393
|
+
const target = mode === 'fork'
|
|
2394
|
+
? forkTarget(sourceEvents, seq)
|
|
2395
|
+
: rewindTarget(sourceEvents, seq);
|
|
2396
|
+
const boundary = target.boundary;
|
|
2397
|
+
if (boundary < 0) {
|
|
2398
|
+
state.notify(t('rewind-first-message'), { color: 'error' });
|
|
2399
|
+
return null;
|
|
2400
|
+
}
|
|
2401
|
+
// Keeping the entry can still be a NO-OP: when nothing message-bearing
|
|
2402
|
+
// follows the boundary (only a turn/end, or nothing at all), the fork's
|
|
2403
|
+
// transcript would be identical to the live one. pi truncates to right
|
|
2404
|
+
// after the entry; DSH's step/turn-closed seed cannot always express
|
|
2405
|
+
// that, so the honest answer is to say there is nothing to rewind. A
|
|
2406
|
+
// DEAD session's tip still forks: that adopts the branch, a real
|
|
2407
|
+
// switch.
|
|
2408
|
+
if (forkFromLive && !sourceEvents.some(event => event.seq > boundary &&
|
|
2409
|
+
(event.type === 'user/message' || event.type === 'assistant/message' ||
|
|
2410
|
+
event.type === 'tool/call' || event.type === 'tool/result'))) {
|
|
2411
|
+
state.notify(t('rewind-noop'), { color: 'warning' });
|
|
2412
|
+
return null;
|
|
2413
|
+
}
|
|
2414
|
+
// The dropped turn's own prompt text, restored into the input after
|
|
2415
|
+
// the swap ('' whenever the entry was kept — fork mode included — or
|
|
2416
|
+
// the turn had no human-typed text to restore).
|
|
2417
|
+
const restoredText = mode === 'fork' ? '' : turnUserText(sourceEvents, seq);
|
|
2418
|
+
// The fork continues under the source session's own preset: switches
|
|
2419
|
+
// are blank-only, so every `agent-preset/selected` event predates any
|
|
2420
|
+
// rewind boundary and the source log resolves the exact composition.
|
|
2421
|
+
// The route likewise stays the live one — a rewind continues the same
|
|
2422
|
+
// conversation, so a `/model` switch must survive it (issue #30).
|
|
2423
|
+
const sourcePreset = forkFromLive
|
|
2424
|
+
? runningPresetOf(entrySession)
|
|
2425
|
+
: ((await resolvePersistedPreset(ctx, SessionId(sessionId))) ?? runningPresetOf(entrySession));
|
|
2426
|
+
const rewindComposed = await composePreset(ctx, sourcePreset);
|
|
2427
|
+
// Everything fallible is done — only NOW stop a running turn (a load
|
|
2428
|
+
// or preset failure above must not kill it). But bail first when the
|
|
2429
|
+
// live session was swapped during those awaits: cancelling/forking
|
|
2430
|
+
// now would hit the NEW session with THIS session's boundary.
|
|
2431
|
+
if (agent.session !== entrySession) {
|
|
2432
|
+
state.notify(t('rewind-session-changed'), { color: 'error' });
|
|
2433
|
+
return null;
|
|
2434
|
+
}
|
|
2435
|
+
// Stop a running turn first and WAIT for its turn/end to land: fork
|
|
2436
|
+
// rejects boundaries inside open turns, and Agent.cancel() closes the
|
|
2437
|
+
// turn asynchronously (a long thinking turn can take seconds to
|
|
2438
|
+
// settle). Cross-session rewinds need this too: the live agent is
|
|
2439
|
+
// about to be disposed, and its turn must close cleanly.
|
|
2440
|
+
const wasWorking = state.working;
|
|
2441
|
+
const cancelSeq = agent.session.seq;
|
|
2442
|
+
if (wasWorking)
|
|
2443
|
+
agent.cancel({ kind: 'user' });
|
|
2444
|
+
if (wasWorking) {
|
|
2445
|
+
const turnSettled = await waitForTurnEnd(agent.session, cancelSeq, 30000);
|
|
2446
|
+
if (!turnSettled) {
|
|
2447
|
+
state.notify(t('rewind-settling'), { color: 'error' });
|
|
2448
|
+
return null;
|
|
2449
|
+
}
|
|
2450
|
+
}
|
|
2451
|
+
// Slice the seed from the PINNED event snapshot. Never sessions.fork
|
|
2452
|
+
// here: fork() rejects a boundary inside an open turn, which is
|
|
2453
|
+
// exactly where a keep-style cut lands (closeTurn set) — close it
|
|
2454
|
+
// with the exact event a real user interrupt writes instead (the
|
|
2455
|
+
// persistence layer closes crash-orphaned turns the same way).
|
|
2456
|
+
// agents.create validates the result itself (contiguous from seq 0,
|
|
2457
|
+
// no open turns).
|
|
2458
|
+
const seed = sourceEvents.filter(event => event.seq <= boundary);
|
|
2459
|
+
if (target.closeTurn !== undefined) {
|
|
2460
|
+
const last = seed[seed.length - 1];
|
|
2461
|
+
if (last !== undefined) {
|
|
2462
|
+
seed.push({
|
|
2463
|
+
type: 'turn/end',
|
|
2464
|
+
seq: last.seq + 1,
|
|
2465
|
+
time: last.time + 1,
|
|
2466
|
+
data: { turn: target.closeTurn, reason: { kind: 'aborted', reason: { kind: 'user' } } },
|
|
2467
|
+
});
|
|
2468
|
+
}
|
|
2469
|
+
}
|
|
2470
|
+
let handle;
|
|
2471
|
+
try {
|
|
2472
|
+
handle = await agents.create({
|
|
2473
|
+
sessionId: childId,
|
|
2474
|
+
seed,
|
|
2475
|
+
meta: {
|
|
2476
|
+
cwd: sourceCwd,
|
|
2477
|
+
parentSession: SessionId(sessionId),
|
|
2478
|
+
seedLength: seed.length,
|
|
2479
|
+
...(rewindComposed.agentPreset === undefined
|
|
2480
|
+
? {}
|
|
2481
|
+
: { agentPreset: rewindComposed.agentPreset }),
|
|
2482
|
+
},
|
|
2483
|
+
agentOptions: { provider: state.provider, model: state.model },
|
|
2484
|
+
...(rewindComposed.setup === undefined ? {} : { setup: rewindComposed.setup }),
|
|
2485
|
+
});
|
|
2486
|
+
}
|
|
2487
|
+
catch {
|
|
2488
|
+
state.notify(t('rewind-create-failed'), { color: 'error' });
|
|
2489
|
+
return null;
|
|
2490
|
+
}
|
|
2491
|
+
try {
|
|
2492
|
+
await attachSessionToWorkspace(ctx, sourceCwd, childId);
|
|
2493
|
+
}
|
|
2494
|
+
catch (error) {
|
|
2495
|
+
state.notify(t('rewind-attach-failed', { err: error instanceof Error ? error.message : String(error) }), { color: 'warning', timeoutMs: 8000 });
|
|
2496
|
+
}
|
|
2497
|
+
// The create await was another swap window: adopting now would dispose
|
|
2498
|
+
// the NEW session's agent. Free the fork we just made and bail.
|
|
2499
|
+
if (agent.session !== entrySession) {
|
|
2500
|
+
void handle.dispose().catch(() => { });
|
|
2501
|
+
state.notify(t('rewind-session-changed'), { color: 'error' });
|
|
2502
|
+
return null;
|
|
2503
|
+
}
|
|
2504
|
+
// Replay the forked history into a fresh transcript (the same swap
|
|
2505
|
+
// tail rewindTo runs), then announce the session switch.
|
|
2506
|
+
const sourceSessionId = adoptForkedAgent(handle, seed, rewindComposed.agentPreset, childId);
|
|
2507
|
+
notifySessionSwitched(mode === 'fork' ? 'fork' : 'rewind', String(childId), sourceSessionId);
|
|
2508
|
+
return restoredText;
|
|
2509
|
+
},
|
|
2510
|
+
async forkSession() {
|
|
2511
|
+
const sessions = ctx.get('sessions');
|
|
2512
|
+
const agents = ctx.get('agents');
|
|
2513
|
+
if (!sessions || !agents) {
|
|
2514
|
+
state.notify(t('fork-unavailable'), { color: 'error' });
|
|
2515
|
+
return false;
|
|
2516
|
+
}
|
|
2517
|
+
// kimi-code /fork semantics: refuse mid-turn instead of cancelling —
|
|
2518
|
+
// the fork must not surprise the user by killing their running turn,
|
|
2519
|
+
// and sessions.fork rejects an open-turn log anyway.
|
|
2520
|
+
if (state.working) {
|
|
2521
|
+
state.notify(t('fork-while-working'), { color: 'warning' });
|
|
2522
|
+
return false;
|
|
2523
|
+
}
|
|
2524
|
+
const source = agent.session;
|
|
2525
|
+
const childId = SessionId(randomUUID());
|
|
2526
|
+
// No boundary: the whole (turn-closed) log. Slice via sessions.fork for
|
|
2527
|
+
// the same validation the rewind path gets, never sessions.fork's
|
|
2528
|
+
// session-storing sibling — agents.create must own the new session.
|
|
2529
|
+
let seed;
|
|
2530
|
+
try {
|
|
2531
|
+
seed = sessions.fork(source).events;
|
|
2532
|
+
}
|
|
2533
|
+
catch (error) {
|
|
2534
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2535
|
+
state.notify(t('fork-failed', { err: message }), { color: 'error' });
|
|
2536
|
+
return false;
|
|
2537
|
+
}
|
|
2538
|
+
// Same preset/route rule as a rewind fork: the source log's own
|
|
2539
|
+
// composition, the live route (a /model switch survives forking).
|
|
2540
|
+
const forkComposed = await composePreset(ctx, runningPresetOf(source));
|
|
2541
|
+
let handle;
|
|
2542
|
+
try {
|
|
2543
|
+
handle = await agents.create({
|
|
2544
|
+
sessionId: childId,
|
|
2545
|
+
seed,
|
|
2546
|
+
meta: {
|
|
2547
|
+
cwd: state.cwd,
|
|
2548
|
+
// NO parentSession: a /fork copy is an independent conversation
|
|
2549
|
+
// (kimi-code semantics — a copy of the message list under a new
|
|
2550
|
+
// root session, like /new plus the history), not a rewind branch.
|
|
2551
|
+
// Recording lineage would fold it into the source's family in
|
|
2552
|
+
// /resume and the user would never find it.
|
|
2553
|
+
seedLength: seed.length,
|
|
2554
|
+
...(forkComposed.agentPreset === undefined
|
|
2555
|
+
? {}
|
|
2556
|
+
: { agentPreset: forkComposed.agentPreset }),
|
|
2557
|
+
},
|
|
2558
|
+
agentOptions: { provider: state.provider, model: state.model },
|
|
2559
|
+
...(forkComposed.setup === undefined ? {} : { setup: forkComposed.setup }),
|
|
2560
|
+
});
|
|
2561
|
+
}
|
|
2562
|
+
catch {
|
|
2563
|
+
state.notify(t('fork-create-failed'), { color: 'error' });
|
|
2564
|
+
return false;
|
|
2565
|
+
}
|
|
2566
|
+
// STAY in the source session: adopting the fork would dispose the live
|
|
2567
|
+
// agent (killing its in-flight turn and background tasks) — the fork is
|
|
2568
|
+
// an independent copy the user enters via /resume or the printed resume
|
|
2569
|
+
// command. The teardown order matters:
|
|
2570
|
+
// 1. attach while the fork's agent is still LIVE — the workspace's
|
|
2571
|
+
// header read resolves live sessions from the registry, so attaching
|
|
2572
|
+
// after dispose races the persistence index and can fail with
|
|
2573
|
+
// "cannot validate session".
|
|
2574
|
+
// 2. await the dispose so the seed log finishes flushing…
|
|
2575
|
+
// 3. …then append the Fork: title — appending mid-flush races the
|
|
2576
|
+
// writer and the frame is silently dropped.
|
|
2577
|
+
try {
|
|
2578
|
+
await attachSessionToWorkspace(ctx, state.cwd, childId);
|
|
2579
|
+
}
|
|
2580
|
+
catch (error) {
|
|
2581
|
+
state.notify(t('fork-attach-failed', { err: error instanceof Error ? error.message : String(error) }), { color: 'warning', timeoutMs: 8000 });
|
|
2582
|
+
}
|
|
2583
|
+
try {
|
|
2584
|
+
await handle.dispose();
|
|
2585
|
+
}
|
|
2586
|
+
catch (error) {
|
|
2587
|
+
ctx.logger.warn('dsh-tui: forked session dispose failed: %o', error);
|
|
2588
|
+
}
|
|
2589
|
+
// kimi's naming convention: the fork wears `Fork: <source title>` (the
|
|
2590
|
+
// prefix stays English in both locales). Best effort — a backend whose
|
|
2591
|
+
// log the compat layer cannot reach just leaves the fork untitled.
|
|
2592
|
+
const sourceTitle = state.sessionTitle.trim();
|
|
2593
|
+
appendSessionTitle(String(childId), `Fork: ${sourceTitle === '' ? String(source.id).slice(0, 8) : sourceTitle}`);
|
|
2594
|
+
// The same resume-command shape the exit hint prints (plugin.ts
|
|
2595
|
+
// resumeCommand): DSH_TUI_RESUME_SESSION + the boot profile.
|
|
2596
|
+
const profile = resolveDshProfileName();
|
|
2597
|
+
const boot = profile === undefined ? 'dsh --config cordis.yml' : `dsh --profile ${profile}`;
|
|
2598
|
+
const command = process.platform === 'win32'
|
|
2599
|
+
? `dsh-tui --resume ${childId}`
|
|
2600
|
+
: `DSH_TUI_RESUME_SESSION=${childId} ${boot}`;
|
|
2601
|
+
state.notify(t('fork-done', { id: String(childId), command }), { timeoutMs: 8000 });
|
|
2602
|
+
return true;
|
|
2603
|
+
},
|
|
1877
2604
|
async resumeTo(sessionId) {
|
|
1878
2605
|
// Switch the live agent to a persisted session: /resume picker Enter
|
|
1879
2606
|
// loads the history immediately (the `--resume` launcher path keeps
|
|
@@ -1912,10 +2639,20 @@ export function createChannel(ctx, initialAgent, options) {
|
|
|
1912
2639
|
provider: options.configuredProvider,
|
|
1913
2640
|
model: options.configuredModel,
|
|
1914
2641
|
});
|
|
2642
|
+
// The recorded route feeds back into agentOptions too — not just the
|
|
2643
|
+
// status line below: a provider-only cordis.yml pin (issue #67) leaves
|
|
2644
|
+
// agentOptions.model undefined on resume, which breaks the `{{model}}`
|
|
2645
|
+
// persona variable for the resumed agent's own assembly AND for every
|
|
2646
|
+
// subagent it spawns (dsh-subagent's resolveChildAgentOptions inherits
|
|
2647
|
+
// `parent.options.model`).
|
|
2648
|
+
const recordedRoute = await resolvePersistedRoute(ctx, SessionId(sessionId));
|
|
1915
2649
|
try {
|
|
1916
2650
|
handle = await agents.resume({
|
|
1917
2651
|
resumeSessionId: SessionId(sessionId),
|
|
1918
|
-
agentOptions: {
|
|
2652
|
+
agentOptions: {
|
|
2653
|
+
provider: resumeRoute?.provider ?? recordedRoute?.provider,
|
|
2654
|
+
model: resumeRoute?.model ?? recordedRoute?.model,
|
|
2655
|
+
},
|
|
1919
2656
|
...(resumeComposed.setup === undefined ? {} : { setup: resumeComposed.setup }),
|
|
1920
2657
|
});
|
|
1921
2658
|
}
|
|
@@ -1954,6 +2691,7 @@ export function createChannel(ctx, initialAgent, options) {
|
|
|
1954
2691
|
state.pending = [];
|
|
1955
2692
|
state.goal = undefined;
|
|
1956
2693
|
state.sessionTitle = '';
|
|
2694
|
+
state.sessionColor = '';
|
|
1957
2695
|
state.tokens = { input: 0, output: 0 };
|
|
1958
2696
|
state.responseChars = 0;
|
|
1959
2697
|
state.activeToolCount = 0;
|
|
@@ -2121,6 +2859,7 @@ export function createChannel(ctx, initialAgent, options) {
|
|
|
2121
2859
|
state.pending = [];
|
|
2122
2860
|
state.goal = undefined;
|
|
2123
2861
|
state.sessionTitle = '';
|
|
2862
|
+
state.sessionColor = '';
|
|
2124
2863
|
state.tokens = { input: 0, output: 0 };
|
|
2125
2864
|
state.responseChars = 0;
|
|
2126
2865
|
state.activeToolCount = 0;
|
|
@@ -2304,6 +3043,7 @@ export function createChannel(ctx, initialAgent, options) {
|
|
|
2304
3043
|
state.pending = [];
|
|
2305
3044
|
state.goal = undefined;
|
|
2306
3045
|
state.sessionTitle = '';
|
|
3046
|
+
state.sessionColor = '';
|
|
2307
3047
|
state.tokens = { input: 0, output: 0 };
|
|
2308
3048
|
state.responseChars = 0;
|
|
2309
3049
|
state.activeToolCount = 0;
|
|
@@ -2437,6 +3177,12 @@ export function createChannel(ctx, initialAgent, options) {
|
|
|
2437
3177
|
state.scrollGutter = normalized;
|
|
2438
3178
|
state.emit();
|
|
2439
3179
|
},
|
|
3180
|
+
setPromptSessionLabel(enabled) {
|
|
3181
|
+
if (enabled === state.promptSessionLabel)
|
|
3182
|
+
return;
|
|
3183
|
+
state.promptSessionLabel = enabled;
|
|
3184
|
+
state.emit();
|
|
3185
|
+
},
|
|
2440
3186
|
setStatusBar(config) {
|
|
2441
3187
|
const next = normalizeStatusBar({ ...state.statusBar, ...config });
|
|
2442
3188
|
const changed = Object.keys(next).some(key => next[key] !== state.statusBar[key]);
|
|
@@ -2567,6 +3313,12 @@ export function createChannel(ctx, initialAgent, options) {
|
|
|
2567
3313
|
return Promise.all(providers.map(provider => llm.listModels(provider.id).catch(() => [])))
|
|
2568
3314
|
.then(lists => lists.flat());
|
|
2569
3315
|
},
|
|
3316
|
+
listProviders() {
|
|
3317
|
+
// Group labels for the two-level /model picker: the registry's own
|
|
3318
|
+
// display names, detached so a registry swap cannot leak through.
|
|
3319
|
+
const llm = ctx.get('llm');
|
|
3320
|
+
return Promise.resolve(llm === undefined ? [] : llm.listProviders().map(info => ({ ...info })));
|
|
3321
|
+
},
|
|
2570
3322
|
async listSkills() {
|
|
2571
3323
|
// snapshot() over list(): only a COMPLETE observation is authoritative
|
|
2572
3324
|
// (same contract as the skill-command merge above) — a partial catalog
|
|
@@ -2663,7 +3415,12 @@ export function createChannel(ctx, initialAgent, options) {
|
|
|
2663
3415
|
return undefined;
|
|
2664
3416
|
}
|
|
2665
3417
|
const revision = () => settings.describe().find(descriptor => descriptor.ns === 'llm-pi-ai')?.revision;
|
|
3418
|
+
// The OAuth sign-in surface (dsh-auth-style plugin), structural and
|
|
3419
|
+
// optional: mounting the plugin lights up the wizard's OAuth branch,
|
|
3420
|
+
// and without it the wizard is exactly what it was before.
|
|
3421
|
+
const oauthApi = ctx.get('dshAuth')?.api;
|
|
2666
3422
|
return {
|
|
3423
|
+
...(oauthApi === undefined ? {} : { oauth: oauthApi }),
|
|
2667
3424
|
listCatalogProviders() {
|
|
2668
3425
|
// declared === true marks routes the adapter knows only because a
|
|
2669
3426
|
// stored profile names them (user-added); the rest are activatable
|
|
@@ -2709,6 +3466,12 @@ export function createChannel(ctx, initialAgent, options) {
|
|
|
2709
3466
|
},
|
|
2710
3467
|
};
|
|
2711
3468
|
},
|
|
3469
|
+
async oauthProviderStatuses() {
|
|
3470
|
+
// Same optional seam the wizard's OAuth branch reads: absent plugin →
|
|
3471
|
+
// undefined, and `/login` renders exactly its pre-plugin lines.
|
|
3472
|
+
const api = ctx.get('dshAuth')?.api;
|
|
3473
|
+
return api === undefined ? undefined : api.providers();
|
|
3474
|
+
},
|
|
2712
3475
|
async sideQuestion(question, options) {
|
|
2713
3476
|
// CC /btw:无工具单轮辅助调用,重放 deriveMessages() 前缀 + 一条
|
|
2714
3477
|
// 包装问题。tools 永不传(侧问无工具是核心语义);usage 不回收
|
|
@@ -2801,6 +3564,61 @@ export function createChannel(ctx, initialAgent, options) {
|
|
|
2801
3564
|
state.sessionTitle = title;
|
|
2802
3565
|
state.emit();
|
|
2803
3566
|
},
|
|
3567
|
+
setSessionColor(color) {
|
|
3568
|
+
// `session/color` is a dsh-tui plugin event — not in dsh-session's
|
|
3569
|
+
// typed union, so appended through the same cast applyMode uses for
|
|
3570
|
+
// its sandbox/approval overrides. It replays on resume/rewind like
|
|
3571
|
+
// session/title, keeping each session's accent color its own.
|
|
3572
|
+
;
|
|
3573
|
+
agent.session
|
|
3574
|
+
.append('session/color', { color });
|
|
3575
|
+
state.sessionColor = color;
|
|
3576
|
+
state.emit();
|
|
3577
|
+
},
|
|
3578
|
+
async recapRecent(options) {
|
|
3579
|
+
// `/recap` (pi-recap semantics): one tool-less LLM call over the
|
|
3580
|
+
// session's TAIL exchanges — unlike /btw it does not replay the full
|
|
3581
|
+
// derived history (the excerpt IS the payload), so it stays cheap.
|
|
3582
|
+
// The answer is pure UI state: never appended to the session log.
|
|
3583
|
+
const llm = ctx.get('llm');
|
|
3584
|
+
if (!llm)
|
|
3585
|
+
return { summary: null, error: t('recap-llm-unavailable') };
|
|
3586
|
+
const header = agent.session.requestHeader();
|
|
3587
|
+
const config = header?.config;
|
|
3588
|
+
const activity = collectRecentActivity(agent.session.events, RECAP_RECENT_CHARS);
|
|
3589
|
+
if (activity === '')
|
|
3590
|
+
return { summary: null, error: t('recap-no-activity') };
|
|
3591
|
+
const messages = [
|
|
3592
|
+
createUserMessage({
|
|
3593
|
+
content: [{ type: 'text', text: wrapRecapPrompt(activity) }],
|
|
3594
|
+
source: { kind: 'plugin', plugin: 'dsh-tui/recap' },
|
|
3595
|
+
}),
|
|
3596
|
+
];
|
|
3597
|
+
const request = {
|
|
3598
|
+
provider: config?.provider ?? state.provider,
|
|
3599
|
+
model: config?.model ?? state.model,
|
|
3600
|
+
messages,
|
|
3601
|
+
...(header?.system !== undefined && { system: header.system }),
|
|
3602
|
+
...(config?.reasoningEffort !== undefined && { reasoningEffort: config.reasoningEffort }),
|
|
3603
|
+
...(config?.temperature !== undefined && { temperature: config.temperature }),
|
|
3604
|
+
...(config?.maxTokens !== undefined && { maxTokens: config.maxTokens }),
|
|
3605
|
+
...(config?.stop !== undefined && { stop: [...config.stop] }),
|
|
3606
|
+
sessionId: agent.session.id,
|
|
3607
|
+
...(options?.signal && { signal: options.signal }),
|
|
3608
|
+
};
|
|
3609
|
+
const outcome = await runSideQuestion({
|
|
3610
|
+
stream: llm.stream.bind(llm),
|
|
3611
|
+
options: request,
|
|
3612
|
+
onText: options?.onText,
|
|
3613
|
+
signal: options?.signal,
|
|
3614
|
+
});
|
|
3615
|
+
if (outcome.answer === null)
|
|
3616
|
+
return { summary: null, error: outcome.error };
|
|
3617
|
+
const parsed = parseRecapResponse(outcome.answer);
|
|
3618
|
+
return parsed.title === undefined
|
|
3619
|
+
? { summary: parsed.summary }
|
|
3620
|
+
: { summary: parsed.summary, title: parsed.title };
|
|
3621
|
+
},
|
|
2804
3622
|
async deleteSession(sessionId) {
|
|
2805
3623
|
// The live session's log is still being appended by this process —
|
|
2806
3624
|
// deleting it from under the writer is never offered in the picker
|
|
@@ -2896,7 +3714,13 @@ export function createChannel(ctx, initialAgent, options) {
|
|
|
2896
3714
|
.catch((error) => {
|
|
2897
3715
|
state.notify(t('compact-failed', { err: error instanceof Error ? error.message : String(error) }), { color: 'error', timeoutMs: 8000 });
|
|
2898
3716
|
});
|
|
2899
|
-
})()
|
|
3717
|
+
})().catch((error) => {
|
|
3718
|
+
// Sync throws from compactNow (e.g. runMaintenance rejecting a
|
|
3719
|
+
// non-idle agent right after /resume) reject this IIFE itself;
|
|
3720
|
+
// uncaught, that is an unhandled rejection and Node exits the
|
|
3721
|
+
// whole TUI. Surface it as the same failure notification.
|
|
3722
|
+
state.notify(t('compact-failed', { err: error instanceof Error ? error.message : String(error) }), { color: 'error', timeoutMs: 8000 });
|
|
3723
|
+
});
|
|
2900
3724
|
},
|
|
2901
3725
|
runExternalCommand(name, rawInput) {
|
|
2902
3726
|
return executeRegistryCommand(name, rawInput);
|
|
@@ -4275,6 +5099,13 @@ ${output}
|
|
|
4275
5099
|
nextRowId += 1;
|
|
4276
5100
|
break;
|
|
4277
5101
|
}
|
|
5102
|
+
// `/color` accent (dsh-tui plugin event, replayed on resume/rewind
|
|
5103
|
+
// like session/title): last write wins, '' clears to the default.
|
|
5104
|
+
if (event.type === 'session/color') {
|
|
5105
|
+
const data = event.data;
|
|
5106
|
+
state.sessionColor = typeof data.color === 'string' ? data.color : '';
|
|
5107
|
+
break;
|
|
5108
|
+
}
|
|
4278
5109
|
// Custom plugin events (tuiRenderers seam): a registered renderer
|
|
4279
5110
|
// maps the payload to text rows — title as a local row, body as
|
|
4280
5111
|
// preview-clipped local-output rows, same shape pushLocal uses.
|
|
@@ -4822,6 +5653,20 @@ const MENTION_IMAGE_MEDIA_TYPES = {
|
|
|
4822
5653
|
function mentionImageMediaType(path) {
|
|
4823
5654
|
return MENTION_IMAGE_MEDIA_TYPES[extname(path).toLowerCase()];
|
|
4824
5655
|
}
|
|
5656
|
+
/** Resolve+stat one candidate path; undefined when it throws OR stats
|
|
5657
|
+
* absent — both are a miss for strip-first mention resolution (issue #359). */
|
|
5658
|
+
async function tryResolveMention(fs, absolute) {
|
|
5659
|
+
try {
|
|
5660
|
+
const target = await fs.resolve(absolute);
|
|
5661
|
+
const info = await fs.stat(target);
|
|
5662
|
+
if (info === undefined)
|
|
5663
|
+
return undefined;
|
|
5664
|
+
return { target, info };
|
|
5665
|
+
}
|
|
5666
|
+
catch {
|
|
5667
|
+
return undefined;
|
|
5668
|
+
}
|
|
5669
|
+
}
|
|
4825
5670
|
/**
|
|
4826
5671
|
* Expand a submitted text's `@` mentions (issue #15) into model-facing
|
|
4827
5672
|
* attachment blocks: supported image files become durable image blocks,
|
|
@@ -4840,68 +5685,99 @@ export async function expandMentions(fs, cwd, text, attachments, stagedImages) {
|
|
|
4840
5685
|
let imageBytes = 0;
|
|
4841
5686
|
if (fs !== undefined) {
|
|
4842
5687
|
for (const mention of mentions) {
|
|
5688
|
+
const display = mention.literal ?? mention.path;
|
|
4843
5689
|
const imageMediaType = mentionImageMediaType(mention.path);
|
|
4844
5690
|
if (budget <= 0 && imageMediaType === undefined)
|
|
4845
5691
|
break;
|
|
4846
5692
|
// Mentions resolve against the session cwd, same as the model-facing fs
|
|
4847
|
-
// tools; absolute paths pass through untouched.
|
|
5693
|
+
// tools; absolute paths pass through untouched. A `#L12-14` line suffix
|
|
5694
|
+
// (issue #359) is stripped before resolution; when the stripped path
|
|
5695
|
+
// misses, the typed literal (suffix intact) gets ONE fallback try so
|
|
5696
|
+
// filenames genuinely containing `#L…` still resolve as whole files.
|
|
4848
5697
|
const absolute = isAbsolute(mention.path) ? mention.path : join(cwd, mention.path);
|
|
4849
|
-
let
|
|
4850
|
-
let
|
|
4851
|
-
|
|
4852
|
-
|
|
4853
|
-
|
|
4854
|
-
|
|
4855
|
-
|
|
4856
|
-
|
|
5698
|
+
let resolved = await tryResolveMention(fs, absolute);
|
|
5699
|
+
let literalFallback = false;
|
|
5700
|
+
if (resolved === undefined && mention.literal !== undefined) {
|
|
5701
|
+
const literalPath = isAbsolute(mention.literal) ? mention.literal : join(cwd, mention.literal);
|
|
5702
|
+
resolved = await tryResolveMention(fs, literalPath);
|
|
5703
|
+
literalFallback = resolved !== undefined;
|
|
5704
|
+
}
|
|
5705
|
+
if (resolved === undefined) {
|
|
5706
|
+
missing.push(display);
|
|
4857
5707
|
continue;
|
|
4858
5708
|
}
|
|
5709
|
+
const { target, info } = resolved;
|
|
5710
|
+
// On a literal-fallback hit the attached file IS the typed name — the
|
|
5711
|
+
// model must see that path, not the suffix-stripped one.
|
|
5712
|
+
const shownPath = literalFallback ? display : mention.path;
|
|
5713
|
+
// …and judge image-ness by the typed extension in that case too.
|
|
5714
|
+
const imageType = literalFallback && mention.literal !== undefined
|
|
5715
|
+
? mentionImageMediaType(mention.literal)
|
|
5716
|
+
: imageMediaType;
|
|
4859
5717
|
if (info?.type === 'file') {
|
|
4860
|
-
if (
|
|
5718
|
+
if (imageType !== undefined && attachments !== undefined && fs.readBytes !== undefined) {
|
|
4861
5719
|
const limits = attachments.imageLimits;
|
|
4862
|
-
if (!limits.mediaTypes.includes(
|
|
4863
|
-
missing.push(
|
|
5720
|
+
if (!limits.mediaTypes.includes(imageType) || imageCount >= limits.maxImagesPerMessage) {
|
|
5721
|
+
missing.push(display);
|
|
4864
5722
|
continue;
|
|
4865
5723
|
}
|
|
4866
5724
|
try {
|
|
4867
5725
|
const data = await fs.readBytes(target, undefined, limits.maxImageBytes);
|
|
4868
5726
|
if (imageBytes + data.byteLength > limits.maxMessageImageBytes) {
|
|
4869
|
-
missing.push(
|
|
5727
|
+
missing.push(display);
|
|
4870
5728
|
continue;
|
|
4871
5729
|
}
|
|
4872
5730
|
const attachment = await attachments.saveImage({
|
|
4873
5731
|
data,
|
|
4874
|
-
mediaType:
|
|
5732
|
+
mediaType: imageType,
|
|
4875
5733
|
name: basename(target.displayPath),
|
|
4876
5734
|
});
|
|
4877
5735
|
blocks.push({ type: 'image', attachment });
|
|
4878
5736
|
imageCount += 1;
|
|
4879
5737
|
imageBytes += data.byteLength;
|
|
4880
|
-
attached.push(
|
|
5738
|
+
attached.push(display);
|
|
4881
5739
|
}
|
|
4882
5740
|
catch {
|
|
4883
|
-
missing.push(
|
|
5741
|
+
missing.push(display);
|
|
4884
5742
|
}
|
|
4885
5743
|
continue;
|
|
4886
5744
|
}
|
|
4887
5745
|
try {
|
|
4888
5746
|
const cap = Math.min(MENTION_MAX_FILE_CHARS, budget);
|
|
4889
|
-
|
|
5747
|
+
const content = await fs.readText(target);
|
|
5748
|
+
let body = content;
|
|
4890
5749
|
let truncated = false;
|
|
4891
|
-
|
|
4892
|
-
|
|
5750
|
+
let header = `<attached-file path="${shownPath}">`;
|
|
5751
|
+
if (mention.startLine !== undefined && !literalFallback) {
|
|
5752
|
+
// Line-range slice (issue #359): 1-based inclusive. An endLine
|
|
5753
|
+
// past EOF clamps to the file; a startLine past EOF falls back
|
|
5754
|
+
// to the whole file with an in-band note — never a silent
|
|
5755
|
+
// empty attach. Line ranges never apply to literal-fallback
|
|
5756
|
+
// hits (those files really are named `…#L…`, no suffix typed).
|
|
5757
|
+
const lines = content.split('\n');
|
|
5758
|
+
if (mention.startLine > lines.length) {
|
|
5759
|
+
header = `<attached-file path="${shownPath}" lines="${mention.startLine}-${mention.endLine}" note="requested lines beyond EOF (file has ${lines.length} line${lines.length === 1 ? '' : 's'}); whole file attached">`;
|
|
5760
|
+
}
|
|
5761
|
+
else {
|
|
5762
|
+
const endLine = Math.min(mention.endLine ?? mention.startLine, lines.length);
|
|
5763
|
+
header = `<attached-file path="${shownPath}" lines="${mention.startLine}${endLine === mention.startLine ? '' : `-${endLine}`}">`;
|
|
5764
|
+
body = lines.slice(mention.startLine - 1, endLine).join('\n');
|
|
5765
|
+
}
|
|
5766
|
+
}
|
|
5767
|
+
if (body.length > cap) {
|
|
5768
|
+
body = body.slice(0, cap);
|
|
4893
5769
|
truncated = true;
|
|
4894
5770
|
}
|
|
4895
|
-
budget -=
|
|
5771
|
+
budget -= body.length;
|
|
4896
5772
|
blocks.push({
|
|
4897
5773
|
type: 'text',
|
|
4898
|
-
text:
|
|
5774
|
+
text: `${header}\n${body}${truncated ? '\n[… truncated]' : ''}\n</attached-file>`,
|
|
4899
5775
|
});
|
|
4900
|
-
attached.push(
|
|
5776
|
+
attached.push(display);
|
|
4901
5777
|
}
|
|
4902
5778
|
catch {
|
|
4903
5779
|
// Binary/undecodable or unreadable — report it like a miss.
|
|
4904
|
-
missing.push(
|
|
5780
|
+
missing.push(display);
|
|
4905
5781
|
}
|
|
4906
5782
|
continue;
|
|
4907
5783
|
}
|
|
@@ -4918,17 +5794,17 @@ export async function expandMentions(fs, cwd, text, attachments, stagedImages) {
|
|
|
4918
5794
|
budget -= body.length;
|
|
4919
5795
|
blocks.push({
|
|
4920
5796
|
type: 'text',
|
|
4921
|
-
text: `<attached-directory path="${
|
|
5797
|
+
text: `<attached-directory path="${shownPath}">\n${body}\n</attached-directory>`,
|
|
4922
5798
|
});
|
|
4923
|
-
attached.push(
|
|
5799
|
+
attached.push(display);
|
|
4924
5800
|
}
|
|
4925
5801
|
catch {
|
|
4926
|
-
missing.push(
|
|
5802
|
+
missing.push(display);
|
|
4927
5803
|
}
|
|
4928
5804
|
continue;
|
|
4929
5805
|
}
|
|
4930
5806
|
// Absent (stat → undefined) or a special file.
|
|
4931
|
-
missing.push(
|
|
5807
|
+
missing.push(display);
|
|
4932
5808
|
}
|
|
4933
5809
|
}
|
|
4934
5810
|
if (attachments !== undefined && stagedImages !== undefined) {
|