@deepseek-harness-tui/dsh-tui 0.5.0
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/LICENSE +21 -0
- package/README.md +235 -0
- package/bin/dsh-tui.js +95 -0
- package/cordis.patch.yml +218 -0
- package/cordis.yml +185 -0
- package/lib/invariant.js +28 -0
- package/lib/types/activityPrefs.d.ts +29 -0
- package/lib/types/activityPrefs.d.ts.map +1 -0
- package/lib/types/activityPrefs.js +60 -0
- package/lib/types/bootstrap/state.d.ts +13 -0
- package/lib/types/bootstrap/state.d.ts.map +1 -0
- package/lib/types/bootstrap/state.js +12 -0
- package/lib/types/cc/cliHighlight.d.ts +20 -0
- package/lib/types/cc/cliHighlight.d.ts.map +1 -0
- package/lib/types/cc/cliHighlight.js +53 -0
- package/lib/types/cc/figures.d.ts +59 -0
- package/lib/types/cc/figures.d.ts.map +1 -0
- package/lib/types/cc/figures.js +69 -0
- package/lib/types/cc/format.d.ts +26 -0
- package/lib/types/cc/format.d.ts.map +1 -0
- package/lib/types/cc/format.js +55 -0
- package/lib/types/cc/hash.d.ts +25 -0
- package/lib/types/cc/hash.d.ts.map +1 -0
- package/lib/types/cc/hash.js +41 -0
- package/lib/types/cc/hyperlink.d.ts +21 -0
- package/lib/types/cc/hyperlink.d.ts.map +1 -0
- package/lib/types/cc/hyperlink.js +31 -0
- package/lib/types/cc/markdown.d.ts +43 -0
- package/lib/types/cc/markdown.d.ts.map +1 -0
- package/lib/types/cc/markdown.js +345 -0
- package/lib/types/cc/spinnerVerbs.d.ts +3 -0
- package/lib/types/cc/spinnerVerbs.d.ts.map +1 -0
- package/lib/types/cc/spinnerVerbs.js +190 -0
- package/lib/types/cc/terminal.d.ts +15 -0
- package/lib/types/cc/terminal.d.ts.map +1 -0
- package/lib/types/cc/terminal.js +84 -0
- package/lib/types/channel.d.ts +672 -0
- package/lib/types/channel.d.ts.map +1 -0
- package/lib/types/channel.js +2427 -0
- package/lib/types/childStderr.d.ts +63 -0
- package/lib/types/childStderr.d.ts.map +1 -0
- package/lib/types/childStderr.js +161 -0
- package/lib/types/commands.d.ts +55 -0
- package/lib/types/commands.d.ts.map +1 -0
- package/lib/types/commands.js +101 -0
- package/lib/types/components/ActivityLine.d.ts +29 -0
- package/lib/types/components/ActivityLine.d.ts.map +1 -0
- package/lib/types/components/ActivityLine.js +47 -0
- package/lib/types/components/ActivityPicker.d.ts +13 -0
- package/lib/types/components/ActivityPicker.d.ts.map +1 -0
- package/lib/types/components/ActivityPicker.js +24 -0
- package/lib/types/components/CommandSuggestions.d.ts +14 -0
- package/lib/types/components/CommandSuggestions.d.ts.map +1 -0
- package/lib/types/components/CommandSuggestions.js +33 -0
- package/lib/types/components/FileSuggestions.d.ts +12 -0
- package/lib/types/components/FileSuggestions.d.ts.map +1 -0
- package/lib/types/components/FileSuggestions.js +26 -0
- package/lib/types/components/GoalTodoPanel.d.ts +13 -0
- package/lib/types/components/GoalTodoPanel.d.ts.map +1 -0
- package/lib/types/components/GoalTodoPanel.js +57 -0
- package/lib/types/components/HelpMenu.d.ts +12 -0
- package/lib/types/components/HelpMenu.d.ts.map +1 -0
- package/lib/types/components/HelpMenu.js +12 -0
- package/lib/types/components/HistorySearchDialog.d.ts +16 -0
- package/lib/types/components/HistorySearchDialog.d.ts.map +1 -0
- package/lib/types/components/HistorySearchDialog.js +31 -0
- package/lib/types/components/InterruptedByUser.d.ts +7 -0
- package/lib/types/components/InterruptedByUser.d.ts.map +1 -0
- package/lib/types/components/InterruptedByUser.js +9 -0
- package/lib/types/components/LoadedContextPanel.d.ts +20 -0
- package/lib/types/components/LoadedContextPanel.d.ts.map +1 -0
- package/lib/types/components/LoadedContextPanel.js +30 -0
- package/lib/types/components/LogoV2.d.ts +23 -0
- package/lib/types/components/LogoV2.d.ts.map +1 -0
- package/lib/types/components/LogoV2.js +95 -0
- package/lib/types/components/Markdown.d.ts +24 -0
- package/lib/types/components/Markdown.d.ts.map +1 -0
- package/lib/types/components/Markdown.js +106 -0
- package/lib/types/components/MarkdownTable.d.ts +17 -0
- package/lib/types/components/MarkdownTable.d.ts.map +1 -0
- package/lib/types/components/MarkdownTable.js +272 -0
- package/lib/types/components/MessageList.d.ts +41 -0
- package/lib/types/components/MessageList.d.ts.map +1 -0
- package/lib/types/components/MessageList.js +303 -0
- package/lib/types/components/ModelPicker.d.ts +14 -0
- package/lib/types/components/ModelPicker.d.ts.map +1 -0
- package/lib/types/components/ModelPicker.js +15 -0
- package/lib/types/components/PresetPicker.d.ts +17 -0
- package/lib/types/components/PresetPicker.d.ts.map +1 -0
- package/lib/types/components/PresetPicker.js +25 -0
- package/lib/types/components/PromptInput.d.ts +67 -0
- package/lib/types/components/PromptInput.d.ts.map +1 -0
- package/lib/types/components/PromptInput.js +778 -0
- package/lib/types/components/ResumePicker.d.ts +15 -0
- package/lib/types/components/ResumePicker.d.ts.map +1 -0
- package/lib/types/components/ResumePicker.js +34 -0
- package/lib/types/components/RewindPicker.d.ts +14 -0
- package/lib/types/components/RewindPicker.d.ts.map +1 -0
- package/lib/types/components/RewindPicker.js +23 -0
- package/lib/types/components/SearchBox.d.ts +20 -0
- package/lib/types/components/SearchBox.d.ts.map +1 -0
- package/lib/types/components/SearchBox.js +33 -0
- package/lib/types/components/Select.d.ts +22 -0
- package/lib/types/components/Select.d.ts.map +1 -0
- package/lib/types/components/Select.js +19 -0
- package/lib/types/components/Spinner/GlimmerMessage.d.ts +19 -0
- package/lib/types/components/Spinner/GlimmerMessage.d.ts.map +1 -0
- package/lib/types/components/Spinner/GlimmerMessage.js +78 -0
- package/lib/types/components/Spinner/SpinnerAnimationRow.d.ts +31 -0
- package/lib/types/components/Spinner/SpinnerAnimationRow.d.ts.map +1 -0
- package/lib/types/components/Spinner/SpinnerAnimationRow.js +162 -0
- package/lib/types/components/Spinner/SpinnerGlyph.d.ts +16 -0
- package/lib/types/components/Spinner/SpinnerGlyph.d.ts.map +1 -0
- package/lib/types/components/Spinner/SpinnerGlyph.js +36 -0
- package/lib/types/components/Spinner/spinnerMode.d.ts +6 -0
- package/lib/types/components/Spinner/spinnerMode.d.ts.map +1 -0
- package/lib/types/components/Spinner/spinnerMode.js +1 -0
- package/lib/types/components/Spinner/spinnerUtils.d.ts +41 -0
- package/lib/types/components/Spinner/spinnerUtils.d.ts.map +1 -0
- package/lib/types/components/Spinner/spinnerUtils.js +104 -0
- package/lib/types/components/Spinner/useStalledAnimation.d.ts +16 -0
- package/lib/types/components/Spinner/useStalledAnimation.d.ts.map +1 -0
- package/lib/types/components/Spinner/useStalledAnimation.js +71 -0
- package/lib/types/components/StreamingMarkdown.d.ts +12 -0
- package/lib/types/components/StreamingMarkdown.d.ts.map +1 -0
- package/lib/types/components/StreamingMarkdown.js +39 -0
- package/lib/types/components/ThemePicker.d.ts +22 -0
- package/lib/types/components/ThemePicker.d.ts.map +1 -0
- package/lib/types/components/ThemePicker.js +51 -0
- package/lib/types/components/ThinkingToggle.d.ts +17 -0
- package/lib/types/components/ThinkingToggle.d.ts.map +1 -0
- package/lib/types/components/ThinkingToggle.js +30 -0
- package/lib/types/components/ToolUseLoader.d.ts +13 -0
- package/lib/types/components/ToolUseLoader.d.ts.map +1 -0
- package/lib/types/components/ToolUseLoader.js +17 -0
- package/lib/types/components/Whale.d.ts +22 -0
- package/lib/types/components/Whale.d.ts.map +1 -0
- package/lib/types/components/Whale.js +78 -0
- package/lib/types/components/WorkingSpinner.d.ts +26 -0
- package/lib/types/components/WorkingSpinner.d.ts.map +1 -0
- package/lib/types/components/WorkingSpinner.js +68 -0
- package/lib/types/components/activityFrames.d.ts +31 -0
- package/lib/types/components/activityFrames.d.ts.map +1 -0
- package/lib/types/components/activityFrames.js +83 -0
- package/lib/types/components/bigfont.d.ts +28 -0
- package/lib/types/components/bigfont.d.ts.map +1 -0
- package/lib/types/components/bigfont.js +91 -0
- package/lib/types/components/design-system/Byline.d.ts +14 -0
- package/lib/types/components/design-system/Byline.d.ts.map +1 -0
- package/lib/types/components/design-system/Byline.js +17 -0
- package/lib/types/components/design-system/Divider.d.ts +40 -0
- package/lib/types/components/design-system/Divider.d.ts.map +1 -0
- package/lib/types/components/design-system/Divider.js +24 -0
- package/lib/types/components/design-system/KeyboardShortcutHint.d.ts +19 -0
- package/lib/types/components/design-system/KeyboardShortcutHint.d.ts.map +1 -0
- package/lib/types/components/design-system/KeyboardShortcutHint.js +14 -0
- package/lib/types/components/design-system/ListItem.d.ts +36 -0
- package/lib/types/components/design-system/ListItem.d.ts.map +1 -0
- package/lib/types/components/design-system/ListItem.js +48 -0
- package/lib/types/components/design-system/LoadingState.d.ts +19 -0
- package/lib/types/components/design-system/LoadingState.d.ts.map +1 -0
- package/lib/types/components/design-system/LoadingState.js +22 -0
- package/lib/types/components/design-system/Pane.d.ts +17 -0
- package/lib/types/components/design-system/Pane.d.ts.map +1 -0
- package/lib/types/components/design-system/Pane.js +15 -0
- package/lib/types/components/design-system/ProgressBar.d.ts +22 -0
- package/lib/types/components/design-system/ProgressBar.d.ts.map +1 -0
- package/lib/types/components/design-system/ProgressBar.js +27 -0
- package/lib/types/components/design-system/StatusIcon.d.ts +15 -0
- package/lib/types/components/design-system/StatusIcon.d.ts.map +1 -0
- package/lib/types/components/design-system/StatusIcon.js +20 -0
- package/lib/types/components/design-system/ThemeProvider.d.ts +11 -0
- package/lib/types/components/design-system/ThemeProvider.d.ts.map +1 -0
- package/lib/types/components/design-system/ThemeProvider.js +135 -0
- package/lib/types/components/design-system/ThemedBox.d.ts +37 -0
- package/lib/types/components/design-system/ThemedBox.d.ts.map +1 -0
- package/lib/types/components/design-system/ThemedBox.js +26 -0
- package/lib/types/components/design-system/ThemedText.d.ts +56 -0
- package/lib/types/components/design-system/ThemedText.d.ts.map +1 -0
- package/lib/types/components/design-system/ThemedText.js +45 -0
- package/lib/types/components/messages/AssistantTextMessage.d.ts +18 -0
- package/lib/types/components/messages/AssistantTextMessage.d.ts.map +1 -0
- package/lib/types/components/messages/AssistantTextMessage.js +15 -0
- package/lib/types/components/messages/AssistantThinkingMessage.d.ts +23 -0
- package/lib/types/components/messages/AssistantThinkingMessage.d.ts.map +1 -0
- package/lib/types/components/messages/AssistantThinkingMessage.js +23 -0
- package/lib/types/components/messages/AssistantToolUseMessage.d.ts +23 -0
- package/lib/types/components/messages/AssistantToolUseMessage.d.ts.map +1 -0
- package/lib/types/components/messages/AssistantToolUseMessage.js +220 -0
- package/lib/types/components/messages/MessageMetadata.d.ts +15 -0
- package/lib/types/components/messages/MessageMetadata.d.ts.map +1 -0
- package/lib/types/components/messages/MessageMetadata.js +20 -0
- package/lib/types/components/messages/UserPromptMessage.d.ts +19 -0
- package/lib/types/components/messages/UserPromptMessage.d.ts.map +1 -0
- package/lib/types/components/messages/UserPromptMessage.js +15 -0
- package/lib/types/components/questions/AskUserQuestionPanel.d.ts +39 -0
- package/lib/types/components/questions/AskUserQuestionPanel.d.ts.map +1 -0
- package/lib/types/components/questions/AskUserQuestionPanel.js +228 -0
- package/lib/types/components/shimmer.d.ts +35 -0
- package/lib/types/components/shimmer.d.ts.map +1 -0
- package/lib/types/components/shimmer.js +43 -0
- package/lib/types/components/whaleFrames.d.ts +25 -0
- package/lib/types/components/whaleFrames.d.ts.map +1 -0
- package/lib/types/components/whaleFrames.js +419 -0
- package/lib/types/customTheme.d.ts +104 -0
- package/lib/types/customTheme.d.ts.map +1 -0
- package/lib/types/customTheme.js +268 -0
- package/lib/types/effortPrefs.d.ts +22 -0
- package/lib/types/effortPrefs.d.ts.map +1 -0
- package/lib/types/effortPrefs.js +45 -0
- package/lib/types/history.d.ts +24 -0
- package/lib/types/history.d.ts.map +1 -0
- package/lib/types/history.js +75 -0
- package/lib/types/hooks/useBlink.d.ts +11 -0
- package/lib/types/hooks/useBlink.d.ts.map +1 -0
- package/lib/types/hooks/useBlink.js +20 -0
- package/lib/types/i18n.d.ts +926 -0
- package/lib/types/i18n.d.ts.map +1 -0
- package/lib/types/i18n.js +356 -0
- package/lib/types/index.d.ts +70 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/index.js +31 -0
- package/lib/types/ink/Ansi.d.ts +11 -0
- package/lib/types/ink/Ansi.d.ts.map +1 -0
- package/lib/types/ink/Ansi.js +269 -0
- package/lib/types/ink/bidi.d.ts +18 -0
- package/lib/types/ink/bidi.d.ts.map +1 -0
- package/lib/types/ink/bidi.js +119 -0
- package/lib/types/ink/clearTerminal.d.ts +22 -0
- package/lib/types/ink/clearTerminal.d.ts.map +1 -0
- package/lib/types/ink/clearTerminal.js +72 -0
- package/lib/types/ink/colorize.d.ts +33 -0
- package/lib/types/ink/colorize.d.ts.map +1 -0
- package/lib/types/ink/colorize.js +213 -0
- package/lib/types/ink/components/AlternateScreen.d.ts +22 -0
- package/lib/types/ink/components/AlternateScreen.d.ts.map +1 -0
- package/lib/types/ink/components/AlternateScreen.js +74 -0
- package/lib/types/ink/components/App.d.ts +73 -0
- package/lib/types/ink/components/App.d.ts.map +1 -0
- package/lib/types/ink/components/App.js +597 -0
- package/lib/types/ink/components/AppContext.d.ts +15 -0
- package/lib/types/ink/components/AppContext.d.ts.map +1 -0
- package/lib/types/ink/components/AppContext.js +11 -0
- package/lib/types/ink/components/Box.d.ts +49 -0
- package/lib/types/ink/components/Box.d.ts.map +1 -0
- package/lib/types/ink/components/Box.js +153 -0
- package/lib/types/ink/components/Button.d.ts +37 -0
- package/lib/types/ink/components/Button.d.ts.map +1 -0
- package/lib/types/ink/components/Button.js +166 -0
- package/lib/types/ink/components/ClockContext.d.ts +10 -0
- package/lib/types/ink/components/ClockContext.d.ts.map +1 -0
- package/lib/types/ink/components/ClockContext.js +108 -0
- package/lib/types/ink/components/CursorDeclarationContext.d.ts +30 -0
- package/lib/types/ink/components/CursorDeclarationContext.d.ts.map +1 -0
- package/lib/types/ink/components/CursorDeclarationContext.js +6 -0
- package/lib/types/ink/components/ErrorOverview.d.ts +7 -0
- package/lib/types/ink/components/ErrorOverview.d.ts.map +1 -0
- package/lib/types/ink/components/ErrorOverview.js +49 -0
- package/lib/types/ink/components/Link.d.ts +8 -0
- package/lib/types/ink/components/Link.d.ts.map +1 -0
- package/lib/types/ink/components/Link.js +33 -0
- package/lib/types/ink/components/Newline.d.ts +13 -0
- package/lib/types/ink/components/Newline.d.ts.map +1 -0
- package/lib/types/ink/components/Newline.js +29 -0
- package/lib/types/ink/components/NoSelect.d.ts +20 -0
- package/lib/types/ink/components/NoSelect.d.ts.map +1 -0
- package/lib/types/ink/components/NoSelect.js +56 -0
- package/lib/types/ink/components/RawAnsi.d.ts +17 -0
- package/lib/types/ink/components/RawAnsi.d.ts.map +1 -0
- package/lib/types/ink/components/RawAnsi.js +45 -0
- package/lib/types/ink/components/ScrollBox.d.ts +78 -0
- package/lib/types/ink/components/ScrollBox.d.ts.map +1 -0
- package/lib/types/ink/components/ScrollBox.js +174 -0
- package/lib/types/ink/components/Spacer.d.ts +6 -0
- package/lib/types/ink/components/Spacer.d.ts.map +1 -0
- package/lib/types/ink/components/Spacer.js +19 -0
- package/lib/types/ink/components/StdinContext.d.ts +32 -0
- package/lib/types/ink/components/StdinContext.d.ts.map +1 -0
- package/lib/types/ink/components/StdinContext.js +16 -0
- package/lib/types/ink/components/TerminalFocusContext.d.ts +11 -0
- package/lib/types/ink/components/TerminalFocusContext.d.ts.map +1 -0
- package/lib/types/ink/components/TerminalFocusContext.js +45 -0
- package/lib/types/ink/components/TerminalSizeContext.d.ts +6 -0
- package/lib/types/ink/components/TerminalSizeContext.d.ts.map +1 -0
- package/lib/types/ink/components/TerminalSizeContext.js +3 -0
- package/lib/types/ink/components/Text.d.ts +56 -0
- package/lib/types/ink/components/Text.d.ts.map +1 -0
- package/lib/types/ink/components/Text.js +194 -0
- package/lib/types/ink/constants.d.ts +3 -0
- package/lib/types/ink/constants.d.ts.map +1 -0
- package/lib/types/ink/constants.js +2 -0
- package/lib/types/ink/cursor.d.ts +11 -0
- package/lib/types/ink/cursor.d.ts.map +1 -0
- package/lib/types/ink/cursor.js +1 -0
- package/lib/types/ink/devtools.d.ts +7 -0
- package/lib/types/ink/devtools.d.ts.map +1 -0
- package/lib/types/ink/devtools.js +1 -0
- package/lib/types/ink/dom.d.ts +178 -0
- package/lib/types/ink/dom.d.ts.map +1 -0
- package/lib/types/ink/dom.js +424 -0
- package/lib/types/ink/events/click-event.d.ts +32 -0
- package/lib/types/ink/events/click-event.d.ts.map +1 -0
- package/lib/types/ink/events/click-event.js +36 -0
- package/lib/types/ink/events/dispatcher.d.ts +63 -0
- package/lib/types/ink/events/dispatcher.d.ts.map +1 -0
- package/lib/types/ink/events/dispatcher.js +194 -0
- package/lib/types/ink/events/emitter.d.ts +20 -0
- package/lib/types/ink/events/emitter.d.ts.map +1 -0
- package/lib/types/ink/events/emitter.js +43 -0
- package/lib/types/ink/events/event-handlers.d.ts +47 -0
- package/lib/types/ink/events/event-handlers.d.ts.map +1 -0
- package/lib/types/ink/events/event-handlers.js +30 -0
- package/lib/types/ink/events/event.d.ts +19 -0
- package/lib/types/ink/events/event.d.ts.map +1 -0
- package/lib/types/ink/events/event.js +22 -0
- package/lib/types/ink/events/focus-event.d.ts +19 -0
- package/lib/types/ink/events/focus-event.d.ts.map +1 -0
- package/lib/types/ink/events/focus-event.js +21 -0
- package/lib/types/ink/events/input-event.d.ts +53 -0
- package/lib/types/ink/events/input-event.d.ts.map +1 -0
- package/lib/types/ink/events/input-event.js +180 -0
- package/lib/types/ink/events/keyboard-event.d.ts +40 -0
- package/lib/types/ink/events/keyboard-event.d.ts.map +1 -0
- package/lib/types/ink/events/keyboard-event.js +65 -0
- package/lib/types/ink/events/paste-event.d.ts +11 -0
- package/lib/types/ink/events/paste-event.d.ts.map +1 -0
- package/lib/types/ink/events/paste-event.js +13 -0
- package/lib/types/ink/events/resize-event.d.ts +13 -0
- package/lib/types/ink/events/resize-event.d.ts.map +1 -0
- package/lib/types/ink/events/resize-event.js +16 -0
- package/lib/types/ink/events/terminal-event.d.ts +112 -0
- package/lib/types/ink/events/terminal-event.d.ts.map +1 -0
- package/lib/types/ink/events/terminal-event.js +131 -0
- package/lib/types/ink/events/terminal-focus-event.d.ts +21 -0
- package/lib/types/ink/events/terminal-focus-event.d.ts.map +1 -0
- package/lib/types/ink/events/terminal-focus-event.js +18 -0
- package/lib/types/ink/focus.d.ts +74 -0
- package/lib/types/ink/focus.d.ts.map +1 -0
- package/lib/types/ink/focus.js +188 -0
- package/lib/types/ink/frame.d.ts +119 -0
- package/lib/types/ink/frame.d.ts.map +1 -0
- package/lib/types/ink/frame.js +43 -0
- package/lib/types/ink/get-max-width.d.ts +20 -0
- package/lib/types/ink/get-max-width.d.ts.map +1 -0
- package/lib/types/ink/get-max-width.js +25 -0
- package/lib/types/ink/hit-test.d.ts +47 -0
- package/lib/types/ink/hit-test.d.ts.map +1 -0
- package/lib/types/ink/hit-test.js +126 -0
- package/lib/types/ink/hooks/use-animation-frame.d.ts +27 -0
- package/lib/types/ink/hooks/use-animation-frame.d.ts.map +1 -0
- package/lib/types/ink/hooks/use-animation-frame.js +48 -0
- package/lib/types/ink/hooks/use-app.d.ts +8 -0
- package/lib/types/ink/hooks/use-app.d.ts.map +1 -0
- package/lib/types/ink/hooks/use-app.js +8 -0
- package/lib/types/ink/hooks/use-copy-on-select.d.ts +22 -0
- package/lib/types/ink/hooks/use-copy-on-select.d.ts.map +1 -0
- package/lib/types/ink/hooks/use-copy-on-select.js +40 -0
- package/lib/types/ink/hooks/use-declared-cursor.d.ts +31 -0
- package/lib/types/ink/hooks/use-declared-cursor.d.ts.map +1 -0
- package/lib/types/ink/hooks/use-declared-cursor.js +65 -0
- package/lib/types/ink/hooks/use-input.d.ts +41 -0
- package/lib/types/ink/hooks/use-input.d.ts.map +1 -0
- package/lib/types/ink/hooks/use-input.js +73 -0
- package/lib/types/ink/hooks/use-interval.d.ts +23 -0
- package/lib/types/ink/hooks/use-interval.d.ts.map +1 -0
- package/lib/types/ink/hooks/use-interval.js +58 -0
- package/lib/types/ink/hooks/use-search-highlight.d.ts +34 -0
- package/lib/types/ink/hooks/use-search-highlight.d.ts.map +1 -0
- package/lib/types/ink/hooks/use-search-highlight.js +35 -0
- package/lib/types/ink/hooks/use-selection.d.ts +44 -0
- package/lib/types/ink/hooks/use-selection.d.ts.map +1 -0
- package/lib/types/ink/hooks/use-selection.js +64 -0
- package/lib/types/ink/hooks/use-stdin.d.ts +8 -0
- package/lib/types/ink/hooks/use-stdin.d.ts.map +1 -0
- package/lib/types/ink/hooks/use-stdin.js +8 -0
- package/lib/types/ink/hooks/use-tab-status.d.ts +18 -0
- package/lib/types/ink/hooks/use-tab-status.d.ts.map +1 -0
- package/lib/types/ink/hooks/use-tab-status.js +58 -0
- package/lib/types/ink/hooks/use-terminal-focus.d.ts +11 -0
- package/lib/types/ink/hooks/use-terminal-focus.d.ts.map +1 -0
- package/lib/types/ink/hooks/use-terminal-focus.js +15 -0
- package/lib/types/ink/hooks/use-terminal-size.d.ts +6 -0
- package/lib/types/ink/hooks/use-terminal-size.d.ts.map +1 -0
- package/lib/types/ink/hooks/use-terminal-size.js +12 -0
- package/lib/types/ink/hooks/use-terminal-title.d.ts +14 -0
- package/lib/types/ink/hooks/use-terminal-title.d.ts.map +1 -0
- package/lib/types/ink/hooks/use-terminal-title.js +30 -0
- package/lib/types/ink/hooks/use-terminal-viewport.d.ts +32 -0
- package/lib/types/ink/hooks/use-terminal-viewport.d.ts.map +1 -0
- package/lib/types/ink/hooks/use-terminal-viewport.js +79 -0
- package/lib/types/ink/ink.d.ts +370 -0
- package/lib/types/ink/ink.d.ts.map +1 -0
- package/lib/types/ink/ink.js +1665 -0
- package/lib/types/ink/instances.d.ts +10 -0
- package/lib/types/ink/instances.d.ts.map +1 -0
- package/lib/types/ink/instances.js +13 -0
- package/lib/types/ink/layout/engine.d.ts +7 -0
- package/lib/types/ink/layout/engine.d.ts.map +1 -0
- package/lib/types/ink/layout/engine.js +8 -0
- package/lib/types/ink/layout/geometry.d.ts +86 -0
- package/lib/types/ink/layout/geometry.d.ts.map +1 -0
- package/lib/types/ink/layout/geometry.js +95 -0
- package/lib/types/ink/layout/node.d.ts +151 -0
- package/lib/types/ink/layout/node.d.ts.map +1 -0
- package/lib/types/ink/layout/node.js +72 -0
- package/lib/types/ink/layout/yoga.d.ts +69 -0
- package/lib/types/ink/layout/yoga.d.ts.map +1 -0
- package/lib/types/ink/layout/yoga.js +249 -0
- package/lib/types/ink/line-width-cache.d.ts +9 -0
- package/lib/types/ink/line-width-cache.d.ts.map +1 -0
- package/lib/types/ink/line-width-cache.js +45 -0
- package/lib/types/ink/log-update.d.ts +57 -0
- package/lib/types/ink/log-update.d.ts.map +1 -0
- package/lib/types/ink/log-update.js +710 -0
- package/lib/types/ink/measure-element.d.ts +19 -0
- package/lib/types/ink/measure-element.d.ts.map +1 -0
- package/lib/types/ink/measure-element.js +10 -0
- package/lib/types/ink/measure-text.d.ts +13 -0
- package/lib/types/ink/measure-text.d.ts.map +1 -0
- package/lib/types/ink/measure-text.js +38 -0
- package/lib/types/ink/node-cache.d.ts +33 -0
- package/lib/types/ink/node-cache.d.ts.map +1 -0
- package/lib/types/ink/node-cache.js +42 -0
- package/lib/types/ink/optimizer.d.ts +18 -0
- package/lib/types/ink/optimizer.d.ts.map +1 -0
- package/lib/types/ink/optimizer.js +83 -0
- package/lib/types/ink/output.d.ts +200 -0
- package/lib/types/ink/output.d.ts.map +1 -0
- package/lib/types/ink/output.js +663 -0
- package/lib/types/ink/parse-keypress.d.ts +132 -0
- package/lib/types/ink/parse-keypress.d.ts.map +1 -0
- package/lib/types/ink/parse-keypress.js +707 -0
- package/lib/types/ink/reconciler.d.ts +43 -0
- package/lib/types/ink/reconciler.d.ts.map +1 -0
- package/lib/types/ink/reconciler.js +413 -0
- package/lib/types/ink/render-border.d.ts +36 -0
- package/lib/types/ink/render-border.d.ts.map +1 -0
- package/lib/types/ink/render-border.js +143 -0
- package/lib/types/ink/render-node-to-output.d.ts +93 -0
- package/lib/types/ink/render-node-to-output.d.ts.map +1 -0
- package/lib/types/ink/render-node-to-output.js +1290 -0
- package/lib/types/ink/render-to-screen.d.ts +65 -0
- package/lib/types/ink/render-to-screen.d.ts.map +1 -0
- package/lib/types/ink/render-to-screen.js +189 -0
- package/lib/types/ink/renderer.d.ts +24 -0
- package/lib/types/ink/renderer.d.ts.map +1 -0
- package/lib/types/ink/renderer.js +136 -0
- package/lib/types/ink/root.d.ts +91 -0
- package/lib/types/ink/root.d.ts.map +1 -0
- package/lib/types/ink/root.js +93 -0
- package/lib/types/ink/screen.d.ts +443 -0
- package/lib/types/ink/screen.d.ts.map +1 -0
- package/lib/types/ink/screen.js +1295 -0
- package/lib/types/ink/searchHighlight.d.ts +25 -0
- package/lib/types/ink/searchHighlight.d.ts.map +1 -0
- package/lib/types/ink/searchHighlight.js +82 -0
- package/lib/types/ink/selection.d.ts +323 -0
- package/lib/types/ink/selection.d.ts.map +1 -0
- package/lib/types/ink/selection.js +881 -0
- package/lib/types/ink/squash-text-nodes.d.ts +30 -0
- package/lib/types/ink/squash-text-nodes.d.ts.map +1 -0
- package/lib/types/ink/squash-text-nodes.js +63 -0
- package/lib/types/ink/stringWidth.d.ts +11 -0
- package/lib/types/ink/stringWidth.d.ts.map +1 -0
- package/lib/types/ink/stringWidth.js +209 -0
- package/lib/types/ink/styles.d.ts +324 -0
- package/lib/types/ink/styles.d.ts.map +1 -0
- package/lib/types/ink/styles.js +307 -0
- package/lib/types/ink/supports-hyperlinks.d.ts +20 -0
- package/lib/types/ink/supports-hyperlinks.d.ts.map +1 -0
- package/lib/types/ink/supports-hyperlinks.js +44 -0
- package/lib/types/ink/tabstops.d.ts +9 -0
- package/lib/types/ink/tabstops.d.ts.map +1 -0
- package/lib/types/ink/tabstops.js +46 -0
- package/lib/types/ink/terminal-focus-state.d.ts +35 -0
- package/lib/types/ink/terminal-focus-state.d.ts.map +1 -0
- package/lib/types/ink/terminal-focus-state.js +57 -0
- package/lib/types/ink/terminal-querier.d.ts +161 -0
- package/lib/types/ink/terminal-querier.d.ts.map +1 -0
- package/lib/types/ink/terminal-querier.js +204 -0
- package/lib/types/ink/terminal.d.ts +80 -0
- package/lib/types/ink/terminal.d.ts.map +1 -0
- package/lib/types/ink/terminal.js +268 -0
- package/lib/types/ink/termio/ansi.d.ts +75 -0
- package/lib/types/ink/termio/ansi.d.ts.map +1 -0
- package/lib/types/ink/termio/ansi.js +79 -0
- package/lib/types/ink/termio/csi.d.ts +253 -0
- package/lib/types/ink/termio/csi.d.ts.map +1 -0
- package/lib/types/ink/termio/csi.js +347 -0
- package/lib/types/ink/termio/dec.d.ts +61 -0
- package/lib/types/ink/termio/dec.d.ts.map +1 -0
- package/lib/types/ink/termio/dec.js +76 -0
- package/lib/types/ink/termio/esc.d.ts +15 -0
- package/lib/types/ink/termio/esc.d.ts.map +1 -0
- package/lib/types/ink/termio/esc.js +57 -0
- package/lib/types/ink/termio/osc.d.ts +208 -0
- package/lib/types/ink/termio/osc.d.ts.map +1 -0
- package/lib/types/ink/termio/osc.js +466 -0
- package/lib/types/ink/termio/parser.d.ts +46 -0
- package/lib/types/ink/termio/parser.d.ts.map +1 -0
- package/lib/types/ink/termio/parser.js +366 -0
- package/lib/types/ink/termio/sgr.d.ts +17 -0
- package/lib/types/ink/termio/sgr.d.ts.map +1 -0
- package/lib/types/ink/termio/sgr.js +300 -0
- package/lib/types/ink/termio/tokenize.d.ts +50 -0
- package/lib/types/ink/termio/tokenize.d.ts.map +1 -0
- package/lib/types/ink/termio/tokenize.js +266 -0
- package/lib/types/ink/termio/types.d.ts +211 -0
- package/lib/types/ink/termio/types.d.ts.map +1 -0
- package/lib/types/ink/termio/types.js +68 -0
- package/lib/types/ink/termio.d.ts +25 -0
- package/lib/types/ink/termio.d.ts.map +1 -0
- package/lib/types/ink/termio.js +24 -0
- package/lib/types/ink/truncateToWidth.d.ts +7 -0
- package/lib/types/ink/truncateToWidth.d.ts.map +1 -0
- package/lib/types/ink/truncateToWidth.js +18 -0
- package/lib/types/ink/useTerminalNotification.d.ts +45 -0
- package/lib/types/ink/useTerminalNotification.d.ts.map +1 -0
- package/lib/types/ink/useTerminalNotification.js +70 -0
- package/lib/types/ink/warn.d.ts +7 -0
- package/lib/types/ink/warn.d.ts.map +1 -0
- package/lib/types/ink/warn.js +15 -0
- package/lib/types/ink/widest-line.d.ts +7 -0
- package/lib/types/ink/widest-line.d.ts.map +1 -0
- package/lib/types/ink/widest-line.js +19 -0
- package/lib/types/ink/wrap-text.d.ts +10 -0
- package/lib/types/ink/wrap-text.d.ts.map +1 -0
- package/lib/types/ink/wrap-text.js +61 -0
- package/lib/types/ink/wrapAnsi.d.ts +18 -0
- package/lib/types/ink/wrapAnsi.d.ts.map +1 -0
- package/lib/types/ink/wrapAnsi.js +16 -0
- package/lib/types/invariant.d.ts +20 -0
- package/lib/types/invariant.d.ts.map +1 -0
- package/lib/types/invariant.js +26 -0
- package/lib/types/modelPrefs.d.ts +37 -0
- package/lib/types/modelPrefs.d.ts.map +1 -0
- package/lib/types/modelPrefs.js +66 -0
- package/lib/types/modelRoute.d.ts +79 -0
- package/lib/types/modelRoute.d.ts.map +1 -0
- package/lib/types/modelRoute.js +96 -0
- package/lib/types/native-ts/yoga-layout/enums.d.ts +183 -0
- package/lib/types/native-ts/yoga-layout/enums.d.ts.map +1 -0
- package/lib/types/native-ts/yoga-layout/enums.js +150 -0
- package/lib/types/native-ts/yoga-layout/index.d.ts +788 -0
- package/lib/types/native-ts/yoga-layout/index.d.ts.map +1 -0
- package/lib/types/native-ts/yoga-layout/index.js +2571 -0
- package/lib/types/packaged-skills.d.ts +10 -0
- package/lib/types/packaged-skills.d.ts.map +1 -0
- package/lib/types/packaged-skills.js +63 -0
- package/lib/types/plugin.d.ts +33 -0
- package/lib/types/plugin.d.ts.map +1 -0
- package/lib/types/plugin.js +454 -0
- package/lib/types/presetPrefs.d.ts +29 -0
- package/lib/types/presetPrefs.d.ts.map +1 -0
- package/lib/types/presetPrefs.js +61 -0
- package/lib/types/presets.d.ts +110 -0
- package/lib/types/presets.d.ts.map +1 -0
- package/lib/types/presets.js +114 -0
- package/lib/types/questions.d.ts +97 -0
- package/lib/types/questions.d.ts.map +1 -0
- package/lib/types/questions.js +201 -0
- package/lib/types/screens/Chat.d.ts +23 -0
- package/lib/types/screens/Chat.d.ts.map +1 -0
- package/lib/types/screens/Chat.js +1263 -0
- package/lib/types/screens/StatusLine.d.ts +16 -0
- package/lib/types/screens/StatusLine.d.ts.map +1 -0
- package/lib/types/screens/StatusLine.js +105 -0
- package/lib/types/screens/StatusMetrics.d.ts +87 -0
- package/lib/types/screens/StatusMetrics.d.ts.map +1 -0
- package/lib/types/screens/StatusMetrics.js +252 -0
- package/lib/types/sessionHistory.d.ts +32 -0
- package/lib/types/sessionHistory.d.ts.map +1 -0
- package/lib/types/sessionHistory.js +87 -0
- package/lib/types/theme.d.ts +115 -0
- package/lib/types/theme.d.ts.map +1 -0
- package/lib/types/theme.js +300 -0
- package/lib/types/themePrefs.d.ts +28 -0
- package/lib/types/themePrefs.d.ts.map +1 -0
- package/lib/types/themePrefs.js +59 -0
- package/lib/types/ui.d.ts +28 -0
- package/lib/types/ui.d.ts.map +1 -0
- package/lib/types/ui.js +25 -0
- package/lib/types/update.d.ts +76 -0
- package/lib/types/update.d.ts.map +1 -0
- package/lib/types/update.js +203 -0
- package/lib/types/utils/clipboard.d.ts +43 -0
- package/lib/types/utils/clipboard.d.ts.map +1 -0
- package/lib/types/utils/clipboard.js +82 -0
- package/lib/types/utils/debug.d.ts +8 -0
- package/lib/types/utils/debug.d.ts.map +1 -0
- package/lib/types/utils/debug.js +12 -0
- package/lib/types/utils/earlyInput.d.ts +7 -0
- package/lib/types/utils/earlyInput.d.ts.map +1 -0
- package/lib/types/utils/earlyInput.js +6 -0
- package/lib/types/utils/env.d.ts +10 -0
- package/lib/types/utils/env.d.ts.map +1 -0
- package/lib/types/utils/env.js +9 -0
- package/lib/types/utils/envUtils.d.ts +8 -0
- package/lib/types/utils/envUtils.d.ts.map +1 -0
- package/lib/types/utils/envUtils.js +13 -0
- package/lib/types/utils/execFileNoThrow.d.ts +34 -0
- package/lib/types/utils/execFileNoThrow.d.ts.map +1 -0
- package/lib/types/utils/execFileNoThrow.js +31 -0
- package/lib/types/utils/fullscreen.d.ts +8 -0
- package/lib/types/utils/fullscreen.d.ts.map +1 -0
- package/lib/types/utils/fullscreen.js +10 -0
- package/lib/types/utils/intl.d.ts +7 -0
- package/lib/types/utils/intl.d.ts.map +1 -0
- package/lib/types/utils/intl.js +9 -0
- package/lib/types/utils/loaded-context.d.ts +20 -0
- package/lib/types/utils/loaded-context.d.ts.map +1 -0
- package/lib/types/utils/loaded-context.js +36 -0
- package/lib/types/utils/log.d.ts +7 -0
- package/lib/types/utils/log.d.ts.map +1 -0
- package/lib/types/utils/log.js +9 -0
- package/lib/types/utils/mentions.d.ts +30 -0
- package/lib/types/utils/mentions.d.ts.map +1 -0
- package/lib/types/utils/mentions.js +72 -0
- package/lib/types/utils/narration.d.ts +11 -0
- package/lib/types/utils/narration.d.ts.map +1 -0
- package/lib/types/utils/narration.js +16 -0
- package/lib/types/utils/semver.d.ts +9 -0
- package/lib/types/utils/semver.d.ts.map +1 -0
- package/lib/types/utils/semver.js +24 -0
- package/lib/types/utils/sliceAnsi.d.ts +12 -0
- package/lib/types/utils/sliceAnsi.d.ts.map +1 -0
- package/lib/types/utils/sliceAnsi.js +79 -0
- package/lib/types/working-activity.d.ts +19 -0
- package/lib/types/working-activity.d.ts.map +1 -0
- package/lib/types/working-activity.js +18 -0
- package/package.json +141 -0
- package/skills/audit/SKILL.md +24 -0
- package/skills/bug/SKILL.md +27 -0
- package/skills/pr-comments/SKILL.md +20 -0
- package/skills/practice/SKILL.md +22 -0
- package/skills/release-notes/SKILL.md +25 -0
- package/skills/review/SKILL.md +25 -0
- package/skills/vuln-check/SKILL.md +25 -0
|
@@ -0,0 +1,2427 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { assembleContextFor, installModelSelection } from '@deepseek-ai/dsh-agent';
|
|
3
|
+
import { createUserMessage, isTokenDelta, MessageId, ReasoningEffortId, } from '@deepseek-ai/dsh-llm';
|
|
4
|
+
import { SessionId } from '@deepseek-ai/dsh-session';
|
|
5
|
+
import { renderContextSections, renderPrompt } from '@deepseek-ai/dsh-system-prompt';
|
|
6
|
+
import { discoverBaselineInstructionFiles } from '@deepseek-ai/dsh-agent-instructions';
|
|
7
|
+
import { isAbsolute, join } from 'node:path';
|
|
8
|
+
import { LOCAL_COMMANDS } from './commands.js';
|
|
9
|
+
import { clearResumeTarget, readLastUsed, touchSession, writeResumeTarget } from './sessionHistory.js';
|
|
10
|
+
import { writeActivityFrames } from './activityPrefs.js';
|
|
11
|
+
import { readEffortPref, writeEffortPref } from './effortPrefs.js';
|
|
12
|
+
import { readModelPref, writeModelPref } from './modelPrefs.js';
|
|
13
|
+
import { explicitModelRoute, recordedModelRoute, resolveModelRoute, validateModelRoute } from './modelRoute.js';
|
|
14
|
+
import { readPresetPref, writePresetPref } from './presetPrefs.js';
|
|
15
|
+
import { composePreset, resolvePersistedPreset, rosterOf, runningPresetOf, serviceForAgent } from './presets.js';
|
|
16
|
+
import { isPresetName } from './components/activityFrames.js';
|
|
17
|
+
import { existsSync, writeFileSync } from 'node:fs';
|
|
18
|
+
import { homedir } from 'node:os';
|
|
19
|
+
import { logForDebugging } from './utils/debug.js';
|
|
20
|
+
import { extractMentions } from './utils/mentions.js';
|
|
21
|
+
import { t } from './i18n.js';
|
|
22
|
+
const ARGS_PREVIEW_LIMIT = 160;
|
|
23
|
+
const RESULT_PREVIEW_LIMIT = 240;
|
|
24
|
+
/** Local `!`-command output cap (mirrors the result preview limit). */
|
|
25
|
+
const LOCAL_OUTPUT_LIMIT = 240;
|
|
26
|
+
/**
|
|
27
|
+
* In-memory transcript window cap. Older rows beyond this count are FOLDED:
|
|
28
|
+
* their full-text fields (assistant/reasoning text, tool args/results) are
|
|
29
|
+
* dropped and only the preview/status metadata kept, so a long merge/deploy
|
|
30
|
+
* turn cannot grow the TUI's RAM without bound. The session log remains the
|
|
31
|
+
* complete source of truth (`/export` reads it, `/resume` replays it); the
|
|
32
|
+
* folded row keeps its kind/id so scrolling and selection stay stable.
|
|
33
|
+
*/
|
|
34
|
+
const MAX_ROWS = 600;
|
|
35
|
+
function preview(text, limit) {
|
|
36
|
+
const flat = text.replace(/\s+/g, ' ').trim();
|
|
37
|
+
return flat.length <= limit ? flat : `${flat.slice(0, limit)}…`;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Fold the oldest rows beyond the transcript window cap: drop each row's
|
|
41
|
+
* full-text fields (assistant/reasoning text, tool args/results) and keep
|
|
42
|
+
* only its preview text, kind, id, and seq. Bounds the TUI's retained text
|
|
43
|
+
* without touching the session log (the source of truth for /export and
|
|
44
|
+
* loadOlder). Small local/notice/interrupt rows are left intact (they hold
|
|
45
|
+
* terminal-local text the log cannot restore). Restored rows are exempt so
|
|
46
|
+
* a loadOlder() restore is not instantly undone. Returns the number of rows
|
|
47
|
+
* folded.
|
|
48
|
+
*/
|
|
49
|
+
function foldRows(rows, cap) {
|
|
50
|
+
const excess = rows.length - cap;
|
|
51
|
+
if (excess <= 0)
|
|
52
|
+
return 0;
|
|
53
|
+
let folded = 0;
|
|
54
|
+
for (const row of rows.slice(0, excess)) {
|
|
55
|
+
if (row.folded || row.restored)
|
|
56
|
+
continue;
|
|
57
|
+
if (row.kind !== 'user' && row.kind !== 'assistant' && row.kind !== 'reasoning' && row.kind !== 'tool')
|
|
58
|
+
continue;
|
|
59
|
+
row.folded = true;
|
|
60
|
+
folded += 1;
|
|
61
|
+
if (row.kind === 'tool' && row.tool) {
|
|
62
|
+
row.tool.argsFull = undefined;
|
|
63
|
+
row.tool.resultFull = undefined;
|
|
64
|
+
row.tool.errorText = undefined;
|
|
65
|
+
// Presentation views hold duplicated content strings (diff before/
|
|
66
|
+
// after images, terminal output); the session log re-derives them.
|
|
67
|
+
row.tool.callView = undefined;
|
|
68
|
+
row.tool.resultView = undefined;
|
|
69
|
+
}
|
|
70
|
+
else if (row.text.length > 0) {
|
|
71
|
+
// Keep a short preview so the transcript reads naturally; the full
|
|
72
|
+
// text lives in the session log and is restored by loadOlder().
|
|
73
|
+
row.text = preview(row.text, 200);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return folded;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Restore folded rows from the session log, newest folded batch first.
|
|
80
|
+
* Rebuilds each folded row's full text from its source events and clears
|
|
81
|
+
* the folded mark, keeping row ids, scroll anchors, and selection stable.
|
|
82
|
+
* `views` re-derives the tool presentation views foldRows dropped (the
|
|
83
|
+
* presenters live on the host plane, so the channel passes them in).
|
|
84
|
+
* Returns the number of rows restored.
|
|
85
|
+
*/
|
|
86
|
+
function foldBack(rows, events, views) {
|
|
87
|
+
const folded = rows.filter(row => row.folded);
|
|
88
|
+
if (folded.length === 0)
|
|
89
|
+
return 0;
|
|
90
|
+
const firstFoldedSeq = folded[0]?.seq ?? 0;
|
|
91
|
+
const restoreEvents = events.filter(event => event.seq >= firstFoldedSeq);
|
|
92
|
+
// tool results are matched by callId, not seq, because the result event
|
|
93
|
+
// seq differs from the call event seq that anchored the row.
|
|
94
|
+
const resultsByCall = new Map();
|
|
95
|
+
for (const event of restoreEvents) {
|
|
96
|
+
if (event.type === 'tool/result') {
|
|
97
|
+
resultsByCall.set(event.data.message.source.callId, event);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
let restored = 0;
|
|
101
|
+
for (const row of folded) {
|
|
102
|
+
const rowSeq = row.seq;
|
|
103
|
+
if (rowSeq === undefined)
|
|
104
|
+
continue;
|
|
105
|
+
if (row.kind === 'tool' && row.tool !== undefined) {
|
|
106
|
+
// The tool row is anchored on its tool/call seq; its result text comes
|
|
107
|
+
// from the matching tool/result event.
|
|
108
|
+
const call = restoreEvents.find(event => event.seq === rowSeq && event.type === 'tool/call');
|
|
109
|
+
if (call === undefined || call.type !== 'tool/call')
|
|
110
|
+
continue;
|
|
111
|
+
restoreRowFromEvent(row, call);
|
|
112
|
+
const result = resultsByCall.get(row.tool.callId);
|
|
113
|
+
if (result !== undefined)
|
|
114
|
+
restoreToolResult(row, result);
|
|
115
|
+
row.tool.callView = views?.call(call.data.name, call.data.arguments);
|
|
116
|
+
row.tool.resultView = result !== undefined && result.data.error === undefined
|
|
117
|
+
? views?.result(call.data.name, call.data.arguments, result.data)
|
|
118
|
+
: undefined;
|
|
119
|
+
row.folded = false;
|
|
120
|
+
restored += 1;
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
// Text rows are anchored on their first delta chunk; the settled
|
|
124
|
+
// assistant/message at or after that seq carries the full text.
|
|
125
|
+
const message = restoreEvents.find(event => event.seq >= rowSeq && event.type === 'assistant/message');
|
|
126
|
+
if (message === undefined)
|
|
127
|
+
continue;
|
|
128
|
+
restoreRowFromEvent(row, message);
|
|
129
|
+
row.folded = false;
|
|
130
|
+
restored += 1;
|
|
131
|
+
}
|
|
132
|
+
return restored;
|
|
133
|
+
}
|
|
134
|
+
/** Rebuild a folded row's full text from its source session event. */
|
|
135
|
+
function restoreRowFromEvent(row, event) {
|
|
136
|
+
switch (row.kind) {
|
|
137
|
+
case 'user': {
|
|
138
|
+
if (event.type !== 'user/message')
|
|
139
|
+
break;
|
|
140
|
+
const text = event.data.content.map(block => block.type === 'text' ? block.text : '').join('').trim();
|
|
141
|
+
if (text)
|
|
142
|
+
row.text = text;
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
case 'assistant': {
|
|
146
|
+
if (event.type !== 'assistant/message')
|
|
147
|
+
break;
|
|
148
|
+
const text = event.data.message.content.map(block => block.type === 'text' ? block.text : '').join('').trim();
|
|
149
|
+
if (text)
|
|
150
|
+
row.text = text;
|
|
151
|
+
break;
|
|
152
|
+
}
|
|
153
|
+
case 'reasoning': {
|
|
154
|
+
// Thinking text is carried by the assistant/message's reasoning
|
|
155
|
+
// blocks, not the (ephemeral) delta chunks, so the settled message
|
|
156
|
+
// restores it exactly.
|
|
157
|
+
if (event.type !== 'assistant/message')
|
|
158
|
+
break;
|
|
159
|
+
const text = event.data.message.content.map(block => block.type === 'reasoning' ? block.text : '').join('').trim();
|
|
160
|
+
if (text)
|
|
161
|
+
row.text = text;
|
|
162
|
+
break;
|
|
163
|
+
}
|
|
164
|
+
case 'tool': {
|
|
165
|
+
if (event.type !== 'tool/call' || row.tool === undefined)
|
|
166
|
+
break;
|
|
167
|
+
row.tool.argsFull = event.data.arguments;
|
|
168
|
+
break;
|
|
169
|
+
}
|
|
170
|
+
default:
|
|
171
|
+
break;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
/** Restore a folded tool row's result text from its tool/result event. */
|
|
175
|
+
function restoreToolResult(row, event) {
|
|
176
|
+
if (row.tool === undefined)
|
|
177
|
+
return;
|
|
178
|
+
const failure = event.data.error;
|
|
179
|
+
if (failure !== undefined) {
|
|
180
|
+
row.tool.status = 'error';
|
|
181
|
+
row.tool.errorText = `${failure.name}: ${failure.code}`;
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
row.tool.status = 'ok';
|
|
185
|
+
const block = event.data.message.content[0];
|
|
186
|
+
const result = block.content.map(b => b.type === 'text' ? b.text : '').join('').trim();
|
|
187
|
+
row.tool.resultFull = result || undefined;
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Coalesce runs of same-type assistant/chunk deltas into single synthetic
|
|
191
|
+
* events for REPLAY only. A streamed turn logs one event per token (~100k
|
|
192
|
+
* events in long sessions); replaying them one at a time costs per-chunk
|
|
193
|
+
* string growth on every row (quadratic in the turn's length). Merging is
|
|
194
|
+
* outcome-identical: ensureStreaming/ensureReasoning only read chunk.type
|
|
195
|
+
* and the concatenated text, and the row's seq comes from the run's FIRST
|
|
196
|
+
* chunk (the fork boundary rewindTo derives from it). Parts join once —
|
|
197
|
+
* no quadratic concat. Live events never go through this.
|
|
198
|
+
*/
|
|
199
|
+
function coalesceReplayEvents(events) {
|
|
200
|
+
const out = [];
|
|
201
|
+
let run = null;
|
|
202
|
+
const flush = () => {
|
|
203
|
+
if (run === null)
|
|
204
|
+
return;
|
|
205
|
+
const chunk = run.event.data.chunk;
|
|
206
|
+
out.push({
|
|
207
|
+
...run.event,
|
|
208
|
+
data: { ...run.event.data, chunk: { ...chunk, text: run.parts.join('') } },
|
|
209
|
+
});
|
|
210
|
+
run = null;
|
|
211
|
+
};
|
|
212
|
+
for (const event of events) {
|
|
213
|
+
if (event.type === 'assistant/chunk' &&
|
|
214
|
+
(event.data.chunk.type === 'text-delta' || event.data.chunk.type === 'reasoning-delta')) {
|
|
215
|
+
if (run !== null && run.type === event.data.chunk.type) {
|
|
216
|
+
// oxlint-disable-next-line typescript/no-unnecessary-condition -- durable replay data may lack text
|
|
217
|
+
run.parts.push(event.data.chunk.text ?? '');
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
flush();
|
|
221
|
+
// oxlint-disable-next-line typescript/no-unnecessary-condition -- durable replay data may lack text
|
|
222
|
+
run = { event, type: event.data.chunk.type, parts: [event.data.chunk.text ?? ''] };
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
flush();
|
|
226
|
+
out.push(event);
|
|
227
|
+
}
|
|
228
|
+
flush();
|
|
229
|
+
return out;
|
|
230
|
+
}
|
|
231
|
+
/** Buffer below the context window at which CC warns (autoCompact.ts). */
|
|
232
|
+
const CONTEXT_WARNING_BUFFER_TOKENS = 20_000;
|
|
233
|
+
/** How many newest sessions resolve their title from the first user message
|
|
234
|
+
* (persistence.load reads the whole log — depth caps the picker latency). */
|
|
235
|
+
const SESSION_TITLE_DEPTH = 20;
|
|
236
|
+
/** Picker title cap, in characters. */
|
|
237
|
+
const SESSION_TITLE_LIMIT = 40;
|
|
238
|
+
/** One-line session title: whitespace folded, capped with an ellipsis. */
|
|
239
|
+
function shortenTitle(text) {
|
|
240
|
+
const flat = text.replace(/\s+/g, ' ').trim();
|
|
241
|
+
return flat.length <= SESSION_TITLE_LIMIT
|
|
242
|
+
? flat
|
|
243
|
+
: `${flat.slice(0, SESSION_TITLE_LIMIT - 1)}…`;
|
|
244
|
+
}
|
|
245
|
+
/** Resolve once a `turn/end` event newer than `fromSeq` lands in the session
|
|
246
|
+
* log (Agent.cancel closes the turn asynchronously), or when the timeout
|
|
247
|
+
* expires. Polling the session log is race-free here: fork reads the same
|
|
248
|
+
* append-only log. */
|
|
249
|
+
async function waitForTurnEnd(session, fromSeq, timeoutMs) {
|
|
250
|
+
const deadline = Date.now() + timeoutMs;
|
|
251
|
+
while (Date.now() < deadline) {
|
|
252
|
+
const last = session.events.at(-1);
|
|
253
|
+
if (last !== undefined && last.type === 'turn/end' && last.seq >= fromSeq) {
|
|
254
|
+
return true;
|
|
255
|
+
}
|
|
256
|
+
await new Promise(resolve => setTimeout(resolve, 200));
|
|
257
|
+
}
|
|
258
|
+
return false;
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Create the live channel state for one agent session: replay the durable
|
|
262
|
+
* transcript, subscribe to the agent's events, and expose every TUI action.
|
|
263
|
+
* @internal
|
|
264
|
+
* @param ctx - The plugin context; optional services are resolved via ctx.get.
|
|
265
|
+
* @param initialAgent - The agent whose session the channel renders; rewinds,
|
|
266
|
+
* resumes, and model switches replace it.
|
|
267
|
+
* @param options - Boot options: model route, cwd, provider, and the
|
|
268
|
+
* reasoning-effort / working-activity / agent-handle preferences.
|
|
269
|
+
* @returns The live channel state, subscribed and ready to render.
|
|
270
|
+
*/
|
|
271
|
+
export function createChannel(ctx, initialAgent, options) {
|
|
272
|
+
let agent = initialAgent;
|
|
273
|
+
let currentHandle = options.handle;
|
|
274
|
+
// The DSH slash-command registry (optional service): /plan, /goal and
|
|
275
|
+
// friends register here; the TUI merges their descriptors into the slash
|
|
276
|
+
// menu and dispatches through `execute` (which logs the paired
|
|
277
|
+
// command/run + command/done records). Absent the service, only the
|
|
278
|
+
// built-in local commands exist.
|
|
279
|
+
const commandService = ctx.get('commands');
|
|
280
|
+
const listeners = new Set();
|
|
281
|
+
/** True while a frame-aligned stream notification is pending (emitStream). */
|
|
282
|
+
let streamNotifyScheduled = false;
|
|
283
|
+
let nextNotificationId = 1;
|
|
284
|
+
/** One-shot context-low warning per session (CC's TokenWarning). */
|
|
285
|
+
let contextWarned = false;
|
|
286
|
+
const checkContextWarning = () => {
|
|
287
|
+
if (contextWarned || state.contextWindow === undefined)
|
|
288
|
+
return;
|
|
289
|
+
const remaining = state.contextWindow - state.tokens.input;
|
|
290
|
+
if (remaining >= CONTEXT_WARNING_BUFFER_TOKENS)
|
|
291
|
+
return;
|
|
292
|
+
contextWarned = true;
|
|
293
|
+
const percentLeft = Math.max(0, Math.round((remaining / state.contextWindow) * 100));
|
|
294
|
+
state.notify(`Context low (${percentLeft}% remaining) · Run /clear or start a new session`, { color: 'warning', timeoutMs: 8000 });
|
|
295
|
+
};
|
|
296
|
+
/**
|
|
297
|
+
* Register a submitted message as pending and notify the UI. The inbox
|
|
298
|
+
* events (claimed/discarded) retire it; nothing here guesses timing.
|
|
299
|
+
*/
|
|
300
|
+
const trackPending = (message, placement) => {
|
|
301
|
+
state.pending = [...state.pending, { id: message.id, text: message.text, placement }];
|
|
302
|
+
state.emit();
|
|
303
|
+
};
|
|
304
|
+
/** Remove one pending entry (rollback on a refused send, steering
|
|
305
|
+
* rejection, or delivery races) and notify only when it existed. */
|
|
306
|
+
const untrackPending = (messageId) => {
|
|
307
|
+
const before = state.pending.length;
|
|
308
|
+
state.pending = state.pending.filter(item => item.id !== messageId);
|
|
309
|
+
if (state.pending.length !== before)
|
|
310
|
+
state.emit();
|
|
311
|
+
};
|
|
312
|
+
/**
|
|
313
|
+
* `@` file mentions (issue #15): expansion reads files asynchronously, so
|
|
314
|
+
* every user-text delivery (submit / steer / interrupt-requeue) funnels
|
|
315
|
+
* through this chain to keep the send order FIFO.
|
|
316
|
+
*/
|
|
317
|
+
let sendChain = Promise.resolve();
|
|
318
|
+
/**
|
|
319
|
+
* Expand the text's `@` mentions and deliver ONE user message: the typed
|
|
320
|
+
* text stays the first content block (the transcript bubble renders it —
|
|
321
|
+
* never the file dump) and each resolved reference appends a model-facing
|
|
322
|
+
* attachment block. The pending preview tracks the typed text.
|
|
323
|
+
*/
|
|
324
|
+
const deliverUserText = (text, placement) => {
|
|
325
|
+
sendChain = sendChain.then(async () => {
|
|
326
|
+
const expansion = await expandMentions(mentionFs(ctx), state.cwd, text);
|
|
327
|
+
const message = createUserMessage({
|
|
328
|
+
content: expansion.blocks,
|
|
329
|
+
source: { kind: 'user' },
|
|
330
|
+
});
|
|
331
|
+
// Track BEFORE the agent call: a synchronous throw inside
|
|
332
|
+
// followup/steer rolls the preview back; otherwise the inbox events
|
|
333
|
+
// retire it once the message is claimed or discarded.
|
|
334
|
+
trackPending({ id: message.id, text }, placement);
|
|
335
|
+
try {
|
|
336
|
+
if (placement === 'steer')
|
|
337
|
+
agent.steer(message);
|
|
338
|
+
else
|
|
339
|
+
agent.followup(message);
|
|
340
|
+
}
|
|
341
|
+
catch (error) {
|
|
342
|
+
untrackPending(message.id);
|
|
343
|
+
throw error;
|
|
344
|
+
}
|
|
345
|
+
if (expansion.attached.length > 0) {
|
|
346
|
+
state.notify(t('mentions-attached', { count: expansion.attached.length }), { timeoutMs: 2500 });
|
|
347
|
+
}
|
|
348
|
+
if (expansion.missing.length > 0) {
|
|
349
|
+
state.notify(t('mentions-missing', { paths: expansion.missing.map(path => `@${path}`).join(' ') }), {
|
|
350
|
+
color: 'warning',
|
|
351
|
+
timeoutMs: 4000,
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
}).catch((error) => {
|
|
355
|
+
// The chain must survive a failed send: log and notify, then continue
|
|
356
|
+
// with the next queued delivery.
|
|
357
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
358
|
+
logForDebugging(`submit: delivery failed (${message})`);
|
|
359
|
+
state.notify(t('send-failed', { err: message }), { color: 'error' });
|
|
360
|
+
});
|
|
361
|
+
};
|
|
362
|
+
/** Monotonic token: only the latest `interruptAndDeliver` re-queues, so a
|
|
363
|
+
* second interrupt while the abort settles cannot double-deliver. */
|
|
364
|
+
let interruptSeq = 0;
|
|
365
|
+
/** The llm runtime seam (dsh-llm LlmRuntime): route metadata resolution. */
|
|
366
|
+
const llmRuntime = ctx.get('llm');
|
|
367
|
+
/** Mutable per-agent model selection (dsh-agent's routing override seam).
|
|
368
|
+
* `current` stays undefined until the user explicitly cycles effort, so
|
|
369
|
+
* default routing (agentOptions on create/fork) is untouched; bindAgent
|
|
370
|
+
* re-couples it to each new agent's prompt assembly + request config. */
|
|
371
|
+
const selection = { current: undefined, assembled: undefined };
|
|
372
|
+
/** The effort chosen this run (or persisted from a previous one); applied
|
|
373
|
+
* to every newly bound agent once validated against its adapter's list. */
|
|
374
|
+
let preferredEffort = options.effort ?? readEffortPref();
|
|
375
|
+
/** Pin `preferredEffort` on the live agent when its route offers it;
|
|
376
|
+
* silent no-op otherwise (the next request/header corrects the display). */
|
|
377
|
+
const applyPreferredEffort = async () => {
|
|
378
|
+
if (preferredEffort === undefined || llmRuntime === undefined)
|
|
379
|
+
return;
|
|
380
|
+
try {
|
|
381
|
+
const info = await llmRuntime.resolveModelInfo(state.provider, state.model);
|
|
382
|
+
if (!info.reasoning?.efforts.some(effort => effort.id === preferredEffort))
|
|
383
|
+
return;
|
|
384
|
+
selection.current = {
|
|
385
|
+
provider: state.provider,
|
|
386
|
+
model: state.model,
|
|
387
|
+
reasoningEffort: ReasoningEffortId(preferredEffort),
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
catch {
|
|
391
|
+
// Route metadata resolution is best-effort; a failure just leaves the
|
|
392
|
+
// provider default in effect.
|
|
393
|
+
}
|
|
394
|
+
};
|
|
395
|
+
/** Shift+Tab: cycle the live route's adapter-owned reasoning efforts in
|
|
396
|
+
* the adapter's own display order (dsh parity — deepseek: Off→High→Max).
|
|
397
|
+
* The choice persists to ~/.dsh-cc/effort.json and follows future agents
|
|
398
|
+
* on this channel (resume/model switch re-validate it per route). */
|
|
399
|
+
const cycleEffort = async () => {
|
|
400
|
+
if (llmRuntime === undefined) {
|
|
401
|
+
state.notify(t('effort-unavailable'), { color: 'error' });
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
let efforts;
|
|
405
|
+
let defaultEffort;
|
|
406
|
+
try {
|
|
407
|
+
const info = await llmRuntime.resolveModelInfo(state.provider, state.model);
|
|
408
|
+
efforts = info.reasoning?.efforts ?? [];
|
|
409
|
+
defaultEffort = info.reasoning?.defaultEffort;
|
|
410
|
+
}
|
|
411
|
+
catch (error) {
|
|
412
|
+
state.notify(t('effort-read-failed', { error: error instanceof Error ? error.message : String(error) }), {
|
|
413
|
+
color: 'error',
|
|
414
|
+
timeoutMs: 8000,
|
|
415
|
+
});
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
if (efforts.length <= 1) {
|
|
419
|
+
state.notify(efforts.length === 1
|
|
420
|
+
? t('effort-single-tier', { name: efforts[0].name })
|
|
421
|
+
: t('effort-unsupported'), { color: 'warning' });
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
// No explicit level yet = the adapter's materialized default is in
|
|
425
|
+
// effect; cycle from THERE, not from the top of the list.
|
|
426
|
+
const currentId = state.reasoningEffort ?? defaultEffort;
|
|
427
|
+
const currentIndex = efforts.findIndex(effort => effort.id === currentId);
|
|
428
|
+
const next = efforts[(currentIndex + 1) % efforts.length];
|
|
429
|
+
selection.current = {
|
|
430
|
+
provider: state.provider,
|
|
431
|
+
model: state.model,
|
|
432
|
+
reasoningEffort: ReasoningEffortId(next.id),
|
|
433
|
+
};
|
|
434
|
+
preferredEffort = next.id;
|
|
435
|
+
state.reasoningEffort = next.id;
|
|
436
|
+
writeEffortPref(next.id);
|
|
437
|
+
state.notify(t('effort-switched', { name: next.name }));
|
|
438
|
+
state.emit();
|
|
439
|
+
};
|
|
440
|
+
const state = {
|
|
441
|
+
version: 0,
|
|
442
|
+
rows: [],
|
|
443
|
+
status: 'starting',
|
|
444
|
+
sessionTitle: '',
|
|
445
|
+
agentId: agent.id,
|
|
446
|
+
model: options.model,
|
|
447
|
+
provider: options.provider,
|
|
448
|
+
tokens: { input: 0, output: 0 },
|
|
449
|
+
cwd: options.cwd,
|
|
450
|
+
gitBranch: undefined,
|
|
451
|
+
working: false,
|
|
452
|
+
spinnerMode: 'requesting',
|
|
453
|
+
responseChars: 0,
|
|
454
|
+
activeToolCount: 0,
|
|
455
|
+
turnStart: 0,
|
|
456
|
+
lastUserText: '',
|
|
457
|
+
notifications: [],
|
|
458
|
+
contextWindow: undefined,
|
|
459
|
+
// Explicit cordis.yml `effort` wins; otherwise the persisted Shift+Tab
|
|
460
|
+
// choice; the first request/header event re-asserts the adapter's truth.
|
|
461
|
+
reasoningEffort: options.effort ?? readEffortPref(),
|
|
462
|
+
workingActivity: undefined,
|
|
463
|
+
activityFrames: options.activityFrames,
|
|
464
|
+
activityEnabled: options.activity !== false,
|
|
465
|
+
contextBarEnabled: options.contextBar !== false,
|
|
466
|
+
agentPreset: options.agentPreset,
|
|
467
|
+
goal: undefined,
|
|
468
|
+
todos: [],
|
|
469
|
+
loadedContext: undefined,
|
|
470
|
+
pending: [],
|
|
471
|
+
commandList: LOCAL_COMMANDS,
|
|
472
|
+
lastUsage: undefined,
|
|
473
|
+
tps: undefined,
|
|
474
|
+
tpsSamples: [],
|
|
475
|
+
contextSegments: {
|
|
476
|
+
system: 0,
|
|
477
|
+
prompt: 0,
|
|
478
|
+
assistant: 0,
|
|
479
|
+
thinking: 0,
|
|
480
|
+
tools: 0,
|
|
481
|
+
},
|
|
482
|
+
subscribe(listener) {
|
|
483
|
+
listeners.add(listener);
|
|
484
|
+
return () => {
|
|
485
|
+
listeners.delete(listener);
|
|
486
|
+
};
|
|
487
|
+
},
|
|
488
|
+
emit() {
|
|
489
|
+
foldRows(state.rows, MAX_ROWS);
|
|
490
|
+
state.version += 1;
|
|
491
|
+
for (const listener of listeners)
|
|
492
|
+
listener();
|
|
493
|
+
},
|
|
494
|
+
// Frame-aligned notification for streaming deltas. LLM chunks arrive at
|
|
495
|
+
// 100-300 events/s (one per token); waking React synchronously per event
|
|
496
|
+
// commits the whole tree per token — the render throttle only gates
|
|
497
|
+
// paint, not commits, so the event loop saturates and output stutters.
|
|
498
|
+
// Data + version stay synchronous (getSnapshot always reads fresh
|
|
499
|
+
// state); only the listener wakeup coalesces to paint cadence.
|
|
500
|
+
emitStream() {
|
|
501
|
+
state.version += 1;
|
|
502
|
+
if (streamNotifyScheduled)
|
|
503
|
+
return;
|
|
504
|
+
streamNotifyScheduled = true;
|
|
505
|
+
const timer = setTimeout(() => {
|
|
506
|
+
streamNotifyScheduled = false;
|
|
507
|
+
foldRows(state.rows, MAX_ROWS);
|
|
508
|
+
for (const listener of listeners)
|
|
509
|
+
listener();
|
|
510
|
+
}, 16);
|
|
511
|
+
// Never hold the process open for a pending UI wakeup.
|
|
512
|
+
timer.unref();
|
|
513
|
+
},
|
|
514
|
+
loadOlder() {
|
|
515
|
+
// Restore folded-away full text from the session log, newest folded
|
|
516
|
+
// batch first, clearing the folded marks. The log is the authoritative
|
|
517
|
+
// source, so restored rows match a fresh replay; live streaming rows
|
|
518
|
+
// are never folded, so nothing here races a running turn.
|
|
519
|
+
const restored = foldBack(state.rows, agent.session.events, { call: presentCallView, result: presentResultView });
|
|
520
|
+
if (restored > 0)
|
|
521
|
+
state.emit();
|
|
522
|
+
return restored;
|
|
523
|
+
},
|
|
524
|
+
submit(text) {
|
|
525
|
+
const trimmed = text.trim();
|
|
526
|
+
if (!trimmed)
|
|
527
|
+
return;
|
|
528
|
+
// Claude Code's `!` mode: `!cmd` runs locally and only shows the
|
|
529
|
+
// output; `!!cmd` additionally sends the output to the model as a
|
|
530
|
+
// user message (CC's <bash-stdout> convention).
|
|
531
|
+
if (trimmed.startsWith('!!')) {
|
|
532
|
+
void runLocalCommand(trimmed.slice(2).trim(), true);
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
if (trimmed.startsWith('!')) {
|
|
536
|
+
void runLocalCommand(trimmed.slice(1).trim(), false);
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
// The current session is being used — move it to the MRU front
|
|
540
|
+
// (/resume sorts by last-used).
|
|
541
|
+
touchSession(state.agentId);
|
|
542
|
+
deliverUserText(trimmed, 'followup');
|
|
543
|
+
},
|
|
544
|
+
/** Steer a message into the RUNNING turn (Codex/pi semantics): it is
|
|
545
|
+
* injected at the next step boundary of the current turn and the agent
|
|
546
|
+
* continues without stopping — faster than followup, never an abort. */
|
|
547
|
+
steer(text) {
|
|
548
|
+
const trimmed = text.trim();
|
|
549
|
+
if (!trimmed)
|
|
550
|
+
return;
|
|
551
|
+
touchSession(state.agentId);
|
|
552
|
+
// Official dsh-agent rc.6: steer() is synchronous void — the message
|
|
553
|
+
// enters the next-step inbox. A rejected step leaves it parked for the
|
|
554
|
+
// next wake; the inbox events below retire the preview (claimed →
|
|
555
|
+
// turn boundary, discarded → cancel).
|
|
556
|
+
deliverUserText(trimmed, 'steer');
|
|
557
|
+
},
|
|
558
|
+
/** Pull a pending message back out of the inbox (Alt+Up): it returns to
|
|
559
|
+
* the input for editing instead of being delivered. */
|
|
560
|
+
removePending(id) {
|
|
561
|
+
const index = state.pending.findIndex(item => item.id === id);
|
|
562
|
+
if (index === -1)
|
|
563
|
+
return false;
|
|
564
|
+
// Official dsh-agent rc.6: withdrawal goes through the agent's inbox
|
|
565
|
+
// projection — `Inbox.remove(messageId)` durably records the
|
|
566
|
+
// cancellation (an `agent/inbox/spliced` session event) and publishes
|
|
567
|
+
// `agent/inbox/discarded`, which retires the preview. Refuse when the
|
|
568
|
+
// message was already claimed (remove returns false) so the UI never
|
|
569
|
+
// pretends a ghost send was pulled back.
|
|
570
|
+
if (!agent.inbox.remove(MessageId(id)))
|
|
571
|
+
return false;
|
|
572
|
+
state.pending = state.pending.filter(item => item.id !== id);
|
|
573
|
+
state.emit();
|
|
574
|
+
return true;
|
|
575
|
+
},
|
|
576
|
+
cancel() {
|
|
577
|
+
// Keep the staged queue: an interrupt aborts the running turn but the
|
|
578
|
+
// queued/steered messages are delivered as the next turn (web parity).
|
|
579
|
+
agent.cancel({ kind: 'user' }, { keepInbox: true });
|
|
580
|
+
},
|
|
581
|
+
interruptAndDeliver(texts) {
|
|
582
|
+
const queued = texts.map(text => text.trim()).filter(text => text !== '');
|
|
583
|
+
if (queued.length === 0)
|
|
584
|
+
return 0;
|
|
585
|
+
// No keepInbox: the parked copies are dropped (their discard events
|
|
586
|
+
// retire the preview), then each text is re-queued as a fresh
|
|
587
|
+
// followup. The harness parks kept inbox work until an unrelated wake
|
|
588
|
+
// (official cancel.spec: "keepInbox parks queued work after an active
|
|
589
|
+
// turn aborts"), and a wake issued while the driver is still aborting
|
|
590
|
+
// is ignored — so the re-queue happens on `whenIdle`, whose own wake
|
|
591
|
+
// starts the new turn.
|
|
592
|
+
agent.cancel({ kind: 'user' });
|
|
593
|
+
const token = ++interruptSeq;
|
|
594
|
+
const whenIdle = agent.whenIdle;
|
|
595
|
+
const deliver = () => {
|
|
596
|
+
// A second interrupt while the abort is still settling must not
|
|
597
|
+
// double-deliver: only the latest request's re-queue runs.
|
|
598
|
+
if (interruptSeq !== token)
|
|
599
|
+
return;
|
|
600
|
+
for (const text of queued) {
|
|
601
|
+
touchSession(state.agentId);
|
|
602
|
+
deliverUserText(text, 'followup');
|
|
603
|
+
}
|
|
604
|
+
};
|
|
605
|
+
if (typeof whenIdle === 'function') {
|
|
606
|
+
void whenIdle.call(agent).then(deliver);
|
|
607
|
+
}
|
|
608
|
+
else {
|
|
609
|
+
// Defensive: a wake while the driver still runs is ignored, so wait
|
|
610
|
+
// for the abort to settle before re-queueing.
|
|
611
|
+
setTimeout(deliver, 200);
|
|
612
|
+
}
|
|
613
|
+
return queued.length;
|
|
614
|
+
},
|
|
615
|
+
async rewindTo(row) {
|
|
616
|
+
if (row.seq === undefined)
|
|
617
|
+
return null;
|
|
618
|
+
const sessions = ctx.get('sessions');
|
|
619
|
+
const agents = ctx.get('agents');
|
|
620
|
+
if (!sessions || !agents) {
|
|
621
|
+
state.notify('Rewind unavailable — session services not loaded', { color: 'error' });
|
|
622
|
+
return null;
|
|
623
|
+
}
|
|
624
|
+
// Stop a running turn first and WAIT for its turn/end to land — fork
|
|
625
|
+
// rejects boundaries inside open turns, and Agent.cancel() closes the
|
|
626
|
+
// turn asynchronously (a long thinking turn can take seconds to settle).
|
|
627
|
+
const wasWorking = state.working;
|
|
628
|
+
const cancelSeq = agent.session.seq;
|
|
629
|
+
if (wasWorking)
|
|
630
|
+
agent.cancel({ kind: 'user' });
|
|
631
|
+
if (wasWorking) {
|
|
632
|
+
const turnSettled = await waitForTurnEnd(agent.session, cancelSeq, 30000);
|
|
633
|
+
if (!turnSettled) {
|
|
634
|
+
state.notify('Cannot rewind — the turn is still settling, try again in a moment', { color: 'error' });
|
|
635
|
+
return null;
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
const childId = SessionId(randomUUID());
|
|
639
|
+
// DSH event order is `turn/start → user/message → … → turn/end`, so a
|
|
640
|
+
// message's own seq always sits inside its turn — forking there would
|
|
641
|
+
// hit OPEN_TURN. Rewind to just BEFORE the message's turn/start: the
|
|
642
|
+
// conversation restarts at that point and the message itself comes
|
|
643
|
+
// back into the input for re-editing (CC's rewind semantics).
|
|
644
|
+
const events = agent.session.events;
|
|
645
|
+
let boundary = row.seq;
|
|
646
|
+
for (let i = row.seq; i >= 0; i--) {
|
|
647
|
+
const event = events[i];
|
|
648
|
+
// oxlint-disable-next-line typescript/no-unnecessary-condition -- runtime guard: seq may exceed events
|
|
649
|
+
if (event === undefined)
|
|
650
|
+
break;
|
|
651
|
+
if (event.type === 'turn/start') {
|
|
652
|
+
boundary = event.seq - 1;
|
|
653
|
+
break;
|
|
654
|
+
}
|
|
655
|
+
if (event.type === 'turn/end')
|
|
656
|
+
break;
|
|
657
|
+
}
|
|
658
|
+
// Slice the seed ourselves instead of storing a fork: agents.create
|
|
659
|
+
// must own the session (a pre-created fork session would collide on
|
|
660
|
+
// the same id). The create boundary validates the seed (contiguous
|
|
661
|
+
// from seq 0, no open turns), which our boundary already guarantees.
|
|
662
|
+
let seed;
|
|
663
|
+
try {
|
|
664
|
+
if (boundary < 0) {
|
|
665
|
+
throw new Error('cannot rewind to the very first message');
|
|
666
|
+
}
|
|
667
|
+
seed = sessions.fork(agent.session, boundary).events;
|
|
668
|
+
}
|
|
669
|
+
catch (error) {
|
|
670
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
671
|
+
state.notify(`Cannot rewind to this point · ${message}`, { color: 'error' });
|
|
672
|
+
return null;
|
|
673
|
+
}
|
|
674
|
+
let handle;
|
|
675
|
+
// The fork continues under the source session's own preset: switches
|
|
676
|
+
// are blank-only, so every `agent-preset/selected` event predates any
|
|
677
|
+
// rewind boundary and the source log resolves the exact composition.
|
|
678
|
+
// The route likewise stays the live one — a rewind continues the same
|
|
679
|
+
// conversation, so a `/model` switch must survive it (issue #30).
|
|
680
|
+
const rewindComposed = await composePreset(ctx, runningPresetOf(agent.session));
|
|
681
|
+
try {
|
|
682
|
+
handle = await agents.create({
|
|
683
|
+
sessionId: childId,
|
|
684
|
+
seed,
|
|
685
|
+
meta: {
|
|
686
|
+
cwd: options.cwd,
|
|
687
|
+
parentSession: agent.session.id,
|
|
688
|
+
seedLength: seed.length,
|
|
689
|
+
...(rewindComposed.agentPreset === undefined
|
|
690
|
+
? {}
|
|
691
|
+
: { agentPreset: rewindComposed.agentPreset }),
|
|
692
|
+
},
|
|
693
|
+
agentOptions: { provider: state.provider, model: state.model },
|
|
694
|
+
...(rewindComposed.setup === undefined ? {} : { setup: rewindComposed.setup }),
|
|
695
|
+
});
|
|
696
|
+
}
|
|
697
|
+
catch {
|
|
698
|
+
state.notify('Rewind failed — could not create the replacement session', { color: 'error' });
|
|
699
|
+
return null;
|
|
700
|
+
}
|
|
701
|
+
// Replay the forked history into a fresh transcript (tokens/spinner
|
|
702
|
+
// counters land back at the rewind point, matching the fork).
|
|
703
|
+
streaming = undefined;
|
|
704
|
+
reasoning = undefined;
|
|
705
|
+
toolCards.clear();
|
|
706
|
+
nextRowId = 0;
|
|
707
|
+
state.rows.length = 0;
|
|
708
|
+
// Goal/todo/title are session-scoped; the replay re-derives them for
|
|
709
|
+
// the session being entered (or leaves them empty).
|
|
710
|
+
state.todos = [];
|
|
711
|
+
state.goal = undefined;
|
|
712
|
+
state.sessionTitle = '';
|
|
713
|
+
state.tokens = { input: 0, output: 0 };
|
|
714
|
+
state.responseChars = 0;
|
|
715
|
+
state.activeToolCount = 0;
|
|
716
|
+
state.lastUserText = '';
|
|
717
|
+
state.working = false;
|
|
718
|
+
state.spinnerMode = 'requesting';
|
|
719
|
+
state.status = handle.agent.status;
|
|
720
|
+
state.agentId = handle.agent.id;
|
|
721
|
+
state.agentPreset = rewindComposed.agentPreset;
|
|
722
|
+
state.tps = undefined;
|
|
723
|
+
state.tpsSamples = [];
|
|
724
|
+
state.lastUsage = undefined;
|
|
725
|
+
state.workingActivity = undefined;
|
|
726
|
+
state.contextSegments = {
|
|
727
|
+
system: 0,
|
|
728
|
+
prompt: 0,
|
|
729
|
+
assistant: 0,
|
|
730
|
+
thinking: 0,
|
|
731
|
+
tools: 0,
|
|
732
|
+
};
|
|
733
|
+
for (const event of coalesceReplayEvents(seed))
|
|
734
|
+
renderEvent(event);
|
|
735
|
+
// Rebind subscriptions to the new agent, then free the old one.
|
|
736
|
+
const oldHandle = currentHandle;
|
|
737
|
+
agent = handle.agent;
|
|
738
|
+
currentHandle = handle;
|
|
739
|
+
bindAgent();
|
|
740
|
+
refreshCommandList();
|
|
741
|
+
void refreshLoadedContext();
|
|
742
|
+
// The forked session (rewind) becomes the most recently used.
|
|
743
|
+
touchSession(childId);
|
|
744
|
+
state.emit();
|
|
745
|
+
void oldHandle?.dispose().catch(() => { });
|
|
746
|
+
return row.text;
|
|
747
|
+
},
|
|
748
|
+
async resumeTo(sessionId) {
|
|
749
|
+
// Switch the live agent to a persisted session: /resume picker Enter
|
|
750
|
+
// loads the history immediately (the `--resume` launcher path keeps
|
|
751
|
+
// resolving through DSH_CC_RESUME_SESSION at boot).
|
|
752
|
+
if (state.working) {
|
|
753
|
+
state.notify('Cannot resume while a turn is running', { color: 'warning' });
|
|
754
|
+
return false;
|
|
755
|
+
}
|
|
756
|
+
const agents = ctx.get('agents');
|
|
757
|
+
if (!agents) {
|
|
758
|
+
state.notify('Resume unavailable — agents service not loaded', { color: 'error' });
|
|
759
|
+
return false;
|
|
760
|
+
}
|
|
761
|
+
let handle;
|
|
762
|
+
// The target session's own preset (from its persisted log) — never the
|
|
763
|
+
// current preference: a resume re-enters the composition its history
|
|
764
|
+
// was produced under. Same rule for the route: only an explicit
|
|
765
|
+
// cordis.yml provider/model overrides the route the target's own
|
|
766
|
+
// request/header records (issue #30) — and only as a COMPLETE pair
|
|
767
|
+
// (issue #67): a provider-only pin must not merge with the recorded
|
|
768
|
+
// model half into a route no adapter recognizes.
|
|
769
|
+
const resumeComposed = await composePreset(ctx, await resolvePersistedPreset(ctx, SessionId(sessionId)));
|
|
770
|
+
const resumeRoute = explicitModelRoute({
|
|
771
|
+
provider: options.configuredProvider,
|
|
772
|
+
model: options.configuredModel,
|
|
773
|
+
});
|
|
774
|
+
try {
|
|
775
|
+
handle = await agents.resume({
|
|
776
|
+
resumeSessionId: SessionId(sessionId),
|
|
777
|
+
agentOptions: { provider: resumeRoute?.provider, model: resumeRoute?.model },
|
|
778
|
+
...(resumeComposed.setup === undefined ? {} : { setup: resumeComposed.setup }),
|
|
779
|
+
});
|
|
780
|
+
}
|
|
781
|
+
catch (error) {
|
|
782
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
783
|
+
state.notify(`Resume failed · ${message}`, { color: 'error', timeoutMs: 8000 });
|
|
784
|
+
return false;
|
|
785
|
+
}
|
|
786
|
+
// Replay the persisted history into a fresh transcript (same reset as
|
|
787
|
+
// rewindTo, plus the context window which the replay re-derives).
|
|
788
|
+
streaming = undefined;
|
|
789
|
+
reasoning = undefined;
|
|
790
|
+
toolCards.clear();
|
|
791
|
+
nextRowId = 0;
|
|
792
|
+
state.rows.length = 0;
|
|
793
|
+
// Goal/todo/title are session-scoped; the replay re-derives them for
|
|
794
|
+
// the session being entered (or leaves them empty).
|
|
795
|
+
state.todos = [];
|
|
796
|
+
state.goal = undefined;
|
|
797
|
+
state.sessionTitle = '';
|
|
798
|
+
state.tokens = { input: 0, output: 0 };
|
|
799
|
+
state.responseChars = 0;
|
|
800
|
+
state.activeToolCount = 0;
|
|
801
|
+
state.lastUserText = '';
|
|
802
|
+
state.working = false;
|
|
803
|
+
state.spinnerMode = 'requesting';
|
|
804
|
+
state.status = handle.agent.status;
|
|
805
|
+
state.agentId = handle.agent.id;
|
|
806
|
+
state.agentPreset = resumeComposed.agentPreset;
|
|
807
|
+
// Status-line route follows the resumed session (review feedback): the
|
|
808
|
+
// route it actually continues on — a complete cordis.yml pin, else the
|
|
809
|
+
// route its own request/header records carry. A bare log (no turn ever
|
|
810
|
+
// started) records none; keep the current display as best effort.
|
|
811
|
+
const resumedRoute = resumeRoute ?? recordedModelRoute(handle.agent.session.events);
|
|
812
|
+
if (resumedRoute !== undefined) {
|
|
813
|
+
state.provider = resumedRoute.provider;
|
|
814
|
+
state.model = resumedRoute.model;
|
|
815
|
+
}
|
|
816
|
+
state.tps = undefined;
|
|
817
|
+
state.tpsSamples = [];
|
|
818
|
+
state.lastUsage = undefined;
|
|
819
|
+
state.workingActivity = undefined;
|
|
820
|
+
state.contextWindow = undefined;
|
|
821
|
+
state.contextSegments = {
|
|
822
|
+
system: 0,
|
|
823
|
+
prompt: 0,
|
|
824
|
+
assistant: 0,
|
|
825
|
+
thinking: 0,
|
|
826
|
+
tools: 0,
|
|
827
|
+
};
|
|
828
|
+
for (const event of coalesceReplayEvents(handle.agent.session.events))
|
|
829
|
+
renderEvent(event);
|
|
830
|
+
settleStreaming();
|
|
831
|
+
// Rebind subscriptions to the resumed agent, then free the old one.
|
|
832
|
+
const oldHandle = currentHandle;
|
|
833
|
+
agent = handle.agent;
|
|
834
|
+
currentHandle = handle;
|
|
835
|
+
bindAgent();
|
|
836
|
+
refreshCommandList();
|
|
837
|
+
void refreshLoadedContext();
|
|
838
|
+
// Keep the `--resume` launcher contract pointing at the same session.
|
|
839
|
+
writeResumeTarget(sessionId);
|
|
840
|
+
// The resumed session is now the most recently used.
|
|
841
|
+
touchSession(sessionId);
|
|
842
|
+
state.emit();
|
|
843
|
+
void oldHandle?.dispose().catch(() => { });
|
|
844
|
+
return true;
|
|
845
|
+
},
|
|
846
|
+
async newSession() {
|
|
847
|
+
// `/new` — start a fresh conversation: brand-new agent + session, the
|
|
848
|
+
// transcript reset, the `--resume` marker forgotten (the old session
|
|
849
|
+
// stays persisted for /resume). Same reset shape as rewindTo/resumeTo.
|
|
850
|
+
if (state.working) {
|
|
851
|
+
state.notify('Cannot start a new session while a turn is running', {
|
|
852
|
+
color: 'warning',
|
|
853
|
+
});
|
|
854
|
+
return false;
|
|
855
|
+
}
|
|
856
|
+
const agents = ctx.get('agents');
|
|
857
|
+
if (!agents) {
|
|
858
|
+
state.notify('New session unavailable — agents service not loaded', {
|
|
859
|
+
color: 'error',
|
|
860
|
+
});
|
|
861
|
+
return false;
|
|
862
|
+
}
|
|
863
|
+
const sessionId = SessionId(randomUUID());
|
|
864
|
+
let handle;
|
|
865
|
+
// A fresh session composes the caller's DEFAULT preset: the cordis.yml
|
|
866
|
+
// `preset` key wins over the persisted `/preset` choice, which wins
|
|
867
|
+
// over the roster default (same precedence as activityFrames).
|
|
868
|
+
const newComposed = await composePreset(ctx, options.configuredPreset ?? readPresetPref());
|
|
869
|
+
// Same precedence for the route (issues #14/#30/#67): the pair resolves
|
|
870
|
+
// atomically — a complete cordis.yml route wins whole, else the
|
|
871
|
+
// persisted `/model` choice (a switch earlier in this run just wrote
|
|
872
|
+
// it, so `/new` follows the live model) wins whole, else the startup
|
|
873
|
+
// route. A stale persisted choice that the adapter catalog rejects
|
|
874
|
+
// falls back to the startup route wholesale, with a warning.
|
|
875
|
+
const newResolved = resolveModelRoute({ provider: options.configuredProvider, model: options.configuredModel }, readModelPref(), { provider: options.provider, model: options.model });
|
|
876
|
+
const newLlm = ctx.get('llm');
|
|
877
|
+
const { route, rejected } = await validateModelRoute(newLlm, newResolved, {
|
|
878
|
+
provider: options.provider,
|
|
879
|
+
model: options.model,
|
|
880
|
+
});
|
|
881
|
+
if (rejected !== undefined) {
|
|
882
|
+
state.notify(t('model-route-invalid', {
|
|
883
|
+
provider: rejected.provider,
|
|
884
|
+
model: rejected.model,
|
|
885
|
+
fallback: `${route.provider}/${route.model}`,
|
|
886
|
+
}), { color: 'warning', timeoutMs: 8000 });
|
|
887
|
+
}
|
|
888
|
+
try {
|
|
889
|
+
handle = await agents.create({
|
|
890
|
+
sessionId,
|
|
891
|
+
meta: {
|
|
892
|
+
cwd: options.cwd,
|
|
893
|
+
...(newComposed.agentPreset === undefined
|
|
894
|
+
? {}
|
|
895
|
+
: { agentPreset: newComposed.agentPreset }),
|
|
896
|
+
},
|
|
897
|
+
agentOptions: route,
|
|
898
|
+
...(newComposed.setup === undefined ? {} : { setup: newComposed.setup }),
|
|
899
|
+
});
|
|
900
|
+
}
|
|
901
|
+
catch (error) {
|
|
902
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
903
|
+
state.notify(`New session failed · ${message}`, {
|
|
904
|
+
color: 'error',
|
|
905
|
+
timeoutMs: 8000,
|
|
906
|
+
});
|
|
907
|
+
return false;
|
|
908
|
+
}
|
|
909
|
+
streaming = undefined;
|
|
910
|
+
reasoning = undefined;
|
|
911
|
+
toolCards.clear();
|
|
912
|
+
nextRowId = 0;
|
|
913
|
+
state.rows.length = 0;
|
|
914
|
+
// Goal/todo/title are session-scoped; the replay re-derives them for
|
|
915
|
+
// the session being entered (or leaves them empty).
|
|
916
|
+
state.todos = [];
|
|
917
|
+
state.goal = undefined;
|
|
918
|
+
state.sessionTitle = '';
|
|
919
|
+
state.tokens = { input: 0, output: 0 };
|
|
920
|
+
state.responseChars = 0;
|
|
921
|
+
state.activeToolCount = 0;
|
|
922
|
+
state.lastUserText = '';
|
|
923
|
+
state.working = false;
|
|
924
|
+
state.spinnerMode = 'requesting';
|
|
925
|
+
state.status = handle.agent.status;
|
|
926
|
+
state.agentId = handle.agent.id;
|
|
927
|
+
state.agentPreset = newComposed.agentPreset;
|
|
928
|
+
state.model = route.model;
|
|
929
|
+
state.provider = route.provider;
|
|
930
|
+
state.tps = undefined;
|
|
931
|
+
state.tpsSamples = [];
|
|
932
|
+
state.lastUsage = undefined;
|
|
933
|
+
state.workingActivity = undefined;
|
|
934
|
+
state.contextWindow = undefined;
|
|
935
|
+
state.contextSegments = {
|
|
936
|
+
system: 0,
|
|
937
|
+
prompt: 0,
|
|
938
|
+
assistant: 0,
|
|
939
|
+
thinking: 0,
|
|
940
|
+
tools: 0,
|
|
941
|
+
};
|
|
942
|
+
const oldHandle = currentHandle;
|
|
943
|
+
agent = handle.agent;
|
|
944
|
+
currentHandle = handle;
|
|
945
|
+
bindAgent();
|
|
946
|
+
refreshCommandList();
|
|
947
|
+
void refreshLoadedContext();
|
|
948
|
+
clearResumeTarget();
|
|
949
|
+
// The brand-new session becomes the most recently used.
|
|
950
|
+
touchSession(handle.agent.id);
|
|
951
|
+
state.emit();
|
|
952
|
+
void oldHandle?.dispose().catch(() => { });
|
|
953
|
+
return true;
|
|
954
|
+
},
|
|
955
|
+
async switchModel(provider, model) {
|
|
956
|
+
// `/model` picker Enter — switch the live model by forking the
|
|
957
|
+
// conversation at its current end and continuing with a new agent
|
|
958
|
+
// routed to the chosen model. Same reset shape as rewindTo/resumeTo;
|
|
959
|
+
// the history replays unchanged, only the request model changes.
|
|
960
|
+
if (state.working) {
|
|
961
|
+
state.notify('Cannot switch models while a turn is running', {
|
|
962
|
+
color: 'warning',
|
|
963
|
+
});
|
|
964
|
+
return false;
|
|
965
|
+
}
|
|
966
|
+
const sessions = ctx.get('sessions');
|
|
967
|
+
const agents = ctx.get('agents');
|
|
968
|
+
if (!sessions || !agents) {
|
|
969
|
+
state.notify('Model switch unavailable — session services not loaded', {
|
|
970
|
+
color: 'error',
|
|
971
|
+
});
|
|
972
|
+
return false;
|
|
973
|
+
}
|
|
974
|
+
let seed;
|
|
975
|
+
try {
|
|
976
|
+
// No boundary = fork the whole log (continue the conversation).
|
|
977
|
+
seed = sessions.fork(agent.session).events;
|
|
978
|
+
}
|
|
979
|
+
catch (error) {
|
|
980
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
981
|
+
state.notify(`Cannot switch models · ${message}`, { color: 'error' });
|
|
982
|
+
return false;
|
|
983
|
+
}
|
|
984
|
+
const childId = SessionId(randomUUID());
|
|
985
|
+
let handle;
|
|
986
|
+
// The forked conversation keeps the session's own preset — only the
|
|
987
|
+
// request route changes (same rule as rewindTo).
|
|
988
|
+
const modelComposed = await composePreset(ctx, runningPresetOf(agent.session));
|
|
989
|
+
try {
|
|
990
|
+
handle = await agents.create({
|
|
991
|
+
sessionId: childId,
|
|
992
|
+
seed,
|
|
993
|
+
meta: {
|
|
994
|
+
cwd: options.cwd,
|
|
995
|
+
parentSession: agent.session.id,
|
|
996
|
+
seedLength: seed.length,
|
|
997
|
+
...(modelComposed.agentPreset === undefined
|
|
998
|
+
? {}
|
|
999
|
+
: { agentPreset: modelComposed.agentPreset }),
|
|
1000
|
+
},
|
|
1001
|
+
agentOptions: { provider, model },
|
|
1002
|
+
...(modelComposed.setup === undefined ? {} : { setup: modelComposed.setup }),
|
|
1003
|
+
});
|
|
1004
|
+
}
|
|
1005
|
+
catch (error) {
|
|
1006
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1007
|
+
state.notify(`Model switch failed · ${message}`, { color: 'error', timeoutMs: 8000 });
|
|
1008
|
+
return false;
|
|
1009
|
+
}
|
|
1010
|
+
streaming = undefined;
|
|
1011
|
+
reasoning = undefined;
|
|
1012
|
+
toolCards.clear();
|
|
1013
|
+
nextRowId = 0;
|
|
1014
|
+
state.rows.length = 0;
|
|
1015
|
+
// Goal/todo/title are session-scoped; the replay re-derives them for
|
|
1016
|
+
// the session being entered (or leaves them empty).
|
|
1017
|
+
state.todos = [];
|
|
1018
|
+
state.goal = undefined;
|
|
1019
|
+
state.sessionTitle = '';
|
|
1020
|
+
state.tokens = { input: 0, output: 0 };
|
|
1021
|
+
state.responseChars = 0;
|
|
1022
|
+
state.activeToolCount = 0;
|
|
1023
|
+
state.lastUserText = '';
|
|
1024
|
+
state.working = false;
|
|
1025
|
+
state.spinnerMode = 'requesting';
|
|
1026
|
+
state.status = handle.agent.status;
|
|
1027
|
+
state.agentId = handle.agent.id;
|
|
1028
|
+
state.agentPreset = modelComposed.agentPreset;
|
|
1029
|
+
state.model = model;
|
|
1030
|
+
state.provider = provider;
|
|
1031
|
+
state.tps = undefined;
|
|
1032
|
+
state.tpsSamples = [];
|
|
1033
|
+
state.lastUsage = undefined;
|
|
1034
|
+
state.workingActivity = undefined;
|
|
1035
|
+
state.contextWindow = undefined;
|
|
1036
|
+
state.contextSegments = {
|
|
1037
|
+
system: 0,
|
|
1038
|
+
prompt: 0,
|
|
1039
|
+
assistant: 0,
|
|
1040
|
+
thinking: 0,
|
|
1041
|
+
tools: 0,
|
|
1042
|
+
};
|
|
1043
|
+
for (const event of coalesceReplayEvents(seed))
|
|
1044
|
+
renderEvent(event);
|
|
1045
|
+
settleStreaming();
|
|
1046
|
+
const oldHandle = currentHandle;
|
|
1047
|
+
agent = handle.agent;
|
|
1048
|
+
currentHandle = handle;
|
|
1049
|
+
bindAgent();
|
|
1050
|
+
refreshCommandList();
|
|
1051
|
+
void refreshLoadedContext();
|
|
1052
|
+
// The model-switched fork becomes the most recently used.
|
|
1053
|
+
touchSession(childId);
|
|
1054
|
+
state.emit();
|
|
1055
|
+
void oldHandle?.dispose().catch(() => { });
|
|
1056
|
+
// Persist the choice so the next boot and `/new` start on it (same
|
|
1057
|
+
// contract as /preset and Shift+Tab effort; issues #14/#30). A failed
|
|
1058
|
+
// write keeps the live switch but warns it will not survive a restart.
|
|
1059
|
+
if (!writeModelPref(provider, model)) {
|
|
1060
|
+
state.notify(t('model-pref-write-failed'), {
|
|
1061
|
+
color: 'warning',
|
|
1062
|
+
});
|
|
1063
|
+
}
|
|
1064
|
+
return true;
|
|
1065
|
+
},
|
|
1066
|
+
cycleEffort,
|
|
1067
|
+
clear() {
|
|
1068
|
+
state.rows.length = 0;
|
|
1069
|
+
nextRowId = 0;
|
|
1070
|
+
streaming = undefined;
|
|
1071
|
+
reasoning = undefined;
|
|
1072
|
+
toolCards.clear();
|
|
1073
|
+
state.activeToolCount = 0;
|
|
1074
|
+
state.responseChars = 0;
|
|
1075
|
+
state.rows.push({
|
|
1076
|
+
id: nextRowId,
|
|
1077
|
+
kind: 'notice',
|
|
1078
|
+
text: 'Session cleared',
|
|
1079
|
+
});
|
|
1080
|
+
nextRowId += 1;
|
|
1081
|
+
state.emit();
|
|
1082
|
+
},
|
|
1083
|
+
notify(text, options = {}) {
|
|
1084
|
+
const item = {
|
|
1085
|
+
id: nextNotificationId++,
|
|
1086
|
+
text,
|
|
1087
|
+
color: options.color,
|
|
1088
|
+
timeoutMs: options.timeoutMs ?? 4000,
|
|
1089
|
+
};
|
|
1090
|
+
state.notifications.push(item);
|
|
1091
|
+
state.emit();
|
|
1092
|
+
setTimeout(() => {
|
|
1093
|
+
const index = state.notifications.indexOf(item);
|
|
1094
|
+
if (index >= 0) {
|
|
1095
|
+
state.notifications.splice(index, 1);
|
|
1096
|
+
state.emit();
|
|
1097
|
+
}
|
|
1098
|
+
}, item.timeoutMs);
|
|
1099
|
+
},
|
|
1100
|
+
setActivityFrames(name) {
|
|
1101
|
+
if (!isPresetName(name)) {
|
|
1102
|
+
state.notify(t('unknown-activity-preset', { name }), { color: 'error' });
|
|
1103
|
+
return false;
|
|
1104
|
+
}
|
|
1105
|
+
if (name === state.activityFrames) {
|
|
1106
|
+
state.notify(t('activity-indicator-already', { name }), { color: 'success' });
|
|
1107
|
+
return true;
|
|
1108
|
+
}
|
|
1109
|
+
// Persist first (pi behavior: a failed write refuses the switch) so a
|
|
1110
|
+
// preference that cannot be saved never silently disappears.
|
|
1111
|
+
if (!writeActivityFrames(name)) {
|
|
1112
|
+
state.notify(t('activity-pref-write-failed'), { color: 'error' });
|
|
1113
|
+
return false;
|
|
1114
|
+
}
|
|
1115
|
+
state.activityFrames = name;
|
|
1116
|
+
state.emit();
|
|
1117
|
+
state.notify(t('activity-indicator-switched', { name }));
|
|
1118
|
+
return true;
|
|
1119
|
+
},
|
|
1120
|
+
async listPresets() {
|
|
1121
|
+
const presets = rosterOf(ctx);
|
|
1122
|
+
if (presets === undefined)
|
|
1123
|
+
return [];
|
|
1124
|
+
try {
|
|
1125
|
+
const list = await presets.list();
|
|
1126
|
+
return list.map(preset => ({
|
|
1127
|
+
id: preset.id,
|
|
1128
|
+
...(preset.name === undefined ? {} : { name: preset.name }),
|
|
1129
|
+
...(preset.description === undefined ? {} : { description: preset.description }),
|
|
1130
|
+
...(preset.broken === undefined ? {} : { broken: preset.broken }),
|
|
1131
|
+
isDefault: preset.id === presets.defaultId,
|
|
1132
|
+
}));
|
|
1133
|
+
}
|
|
1134
|
+
catch {
|
|
1135
|
+
return [];
|
|
1136
|
+
}
|
|
1137
|
+
},
|
|
1138
|
+
async switchPreset(presetId) {
|
|
1139
|
+
const presets = rosterOf(ctx);
|
|
1140
|
+
if (presets === undefined) {
|
|
1141
|
+
state.notify(t('preset-unavailable'), { color: 'error' });
|
|
1142
|
+
return false;
|
|
1143
|
+
}
|
|
1144
|
+
if (state.working) {
|
|
1145
|
+
state.notify(t('preset-agent-running'), { color: 'warning' });
|
|
1146
|
+
return false;
|
|
1147
|
+
}
|
|
1148
|
+
let target;
|
|
1149
|
+
try {
|
|
1150
|
+
target = await presets.resolve(presetId);
|
|
1151
|
+
}
|
|
1152
|
+
catch (error) {
|
|
1153
|
+
state.notify(t('preset-not-found', { id: presetId, err: error instanceof Error ? error.message : String(error) }), { color: 'error', timeoutMs: 8000 });
|
|
1154
|
+
return false;
|
|
1155
|
+
}
|
|
1156
|
+
if (target.broken !== undefined) {
|
|
1157
|
+
state.notify(t('preset-load-failed', { id: presetId, broken: target.broken }), { color: 'error', timeoutMs: 8000 });
|
|
1158
|
+
return false;
|
|
1159
|
+
}
|
|
1160
|
+
if (target.id === state.agentPreset) {
|
|
1161
|
+
state.notify(t('preset-already-current', { id: target.id }), { color: 'success' });
|
|
1162
|
+
return true;
|
|
1163
|
+
}
|
|
1164
|
+
// Official rule (dsh-agent-presets): only a session that has produced
|
|
1165
|
+
// nothing may swap compositions — a started session's logged tool calls
|
|
1166
|
+
// would strand under a different tool set. Blank = no turn ever ran.
|
|
1167
|
+
const blank = !agent.session.events.some(event => event.type === 'turn/start');
|
|
1168
|
+
if (!blank) {
|
|
1169
|
+
// Persist as the default for future sessions instead of failing.
|
|
1170
|
+
if (!writePresetPref(target.id)) {
|
|
1171
|
+
state.notify(t('preset-pref-write-failed'), { color: 'error' });
|
|
1172
|
+
return false;
|
|
1173
|
+
}
|
|
1174
|
+
state.notify(t('preset-locked-saved-default', { current: state.agentPreset ?? 'host', id: target.id }), { color: 'warning', timeoutMs: 8000 });
|
|
1175
|
+
return true;
|
|
1176
|
+
}
|
|
1177
|
+
try {
|
|
1178
|
+
const preset = await presets.recompose(agent.ctx, target.id);
|
|
1179
|
+
// The switch is a logged session fact (model-visible ⟺ logged):
|
|
1180
|
+
// resumes/forks of this session resolve the NEW composition. The
|
|
1181
|
+
// type is runtime-registered in dsh-session's known-event set but
|
|
1182
|
+
// not yet in its typed SessionEventMap — cast the SESSION (never
|
|
1183
|
+
// extract the method: `append` reads the private `this.log`, so an
|
|
1184
|
+
// unbound call throws "Cannot read properties of undefined").
|
|
1185
|
+
const session = agent.session;
|
|
1186
|
+
session.append('agent-preset/selected', { agentPreset: preset.id });
|
|
1187
|
+
state.agentPreset = preset.id;
|
|
1188
|
+
}
|
|
1189
|
+
catch (error) {
|
|
1190
|
+
state.notify(t('preset-switch-failed', { err: error instanceof Error ? error.message : String(error) }), { color: 'error', timeoutMs: 8000 });
|
|
1191
|
+
return false;
|
|
1192
|
+
}
|
|
1193
|
+
state.emit();
|
|
1194
|
+
if (!writePresetPref(target.id)) {
|
|
1195
|
+
state.notify(t('preset-switched-pref-failed', { id: target.id }), { color: 'warning' });
|
|
1196
|
+
return true;
|
|
1197
|
+
}
|
|
1198
|
+
state.notify(t('preset-switched-saved', { id: target.id }), { color: 'success' });
|
|
1199
|
+
return true;
|
|
1200
|
+
},
|
|
1201
|
+
listModels() {
|
|
1202
|
+
const llm = ctx.get('llm');
|
|
1203
|
+
if (!llm)
|
|
1204
|
+
return Promise.resolve([]);
|
|
1205
|
+
const providers = llm.listProviders();
|
|
1206
|
+
return Promise.all(providers.map(provider => llm.listModels(provider.id).catch(() => [])))
|
|
1207
|
+
.then(lists => lists.flat());
|
|
1208
|
+
},
|
|
1209
|
+
listFiles() {
|
|
1210
|
+
const fs = ctx.get('fs');
|
|
1211
|
+
return listFilesDeep(fs, state.cwd);
|
|
1212
|
+
},
|
|
1213
|
+
async listSessions() {
|
|
1214
|
+
// DSH's own session index: the persistence backend materializes one
|
|
1215
|
+
// entry per durable session log (headers carry cwd + createdAt).
|
|
1216
|
+
const persistence = ctx.get('sessionPersistence');
|
|
1217
|
+
if (!persistence)
|
|
1218
|
+
return [];
|
|
1219
|
+
try {
|
|
1220
|
+
const headers = await persistence.list();
|
|
1221
|
+
// 按工作目录隔离(Claude Code 的项目维度):/resume 只列出本会话
|
|
1222
|
+
// 目录启动的会话,别的项目的会话不出现在选择器里。
|
|
1223
|
+
const cwd = state.cwd.replace(/\/+$/, '');
|
|
1224
|
+
const local = headers.filter(header => (header.cwd ?? '').replace(/\/+$/, '') === cwd);
|
|
1225
|
+
// MRU ordering: DSH headers carry only createdAt, so dsh-tui keeps its
|
|
1226
|
+
// own last-used timestamps (touchSession on resume/submit/new) and
|
|
1227
|
+
// falls back to createdAt for sessions never touched in this install.
|
|
1228
|
+
const lastUsed = readLastUsed();
|
|
1229
|
+
const records = local
|
|
1230
|
+
.map(header => ({
|
|
1231
|
+
id: header.id,
|
|
1232
|
+
// Titles load lazily below (first user message); until then the
|
|
1233
|
+
// cwd basename stands in (matching the status line), with a
|
|
1234
|
+
// short id when absent.
|
|
1235
|
+
title: basename(header.cwd ?? '') || `session ${String(header.id).slice(0, 8)}`,
|
|
1236
|
+
cwd: header.cwd ?? '',
|
|
1237
|
+
createdAt: header.createdAt,
|
|
1238
|
+
updatedAt: lastUsed[header.id] ?? header.createdAt,
|
|
1239
|
+
}))
|
|
1240
|
+
.sort((a, b) => b.updatedAt - a.updatedAt);
|
|
1241
|
+
// Title = the session's FIRST user message — the picker's most
|
|
1242
|
+
// useful label. persistence.load reads the whole log (zstd), so
|
|
1243
|
+
// only the newest sessions pay for it; older rows keep the
|
|
1244
|
+
// basename fallback. A load failure degrades silently.
|
|
1245
|
+
const empty = new Set();
|
|
1246
|
+
await Promise.all(records.slice(0, SESSION_TITLE_DEPTH).map(async (record) => {
|
|
1247
|
+
try {
|
|
1248
|
+
const { events } = await persistence.load(SessionId(record.id));
|
|
1249
|
+
const first = events.find(event => event.type === 'user/message');
|
|
1250
|
+
if (first === undefined) {
|
|
1251
|
+
// Launch artifact — a session with no user message holds no
|
|
1252
|
+
// conversation to resume, so drop it from the picker (its
|
|
1253
|
+
// createdAt-only updatedAt would otherwise pin it near the
|
|
1254
|
+
// top forever, one per dsh-tui launch).
|
|
1255
|
+
empty.add(record.id);
|
|
1256
|
+
return;
|
|
1257
|
+
}
|
|
1258
|
+
const data = first.data;
|
|
1259
|
+
const text = firstTextOf(data.content);
|
|
1260
|
+
if (text.length > 0)
|
|
1261
|
+
record.title = shortenTitle(text);
|
|
1262
|
+
}
|
|
1263
|
+
catch {
|
|
1264
|
+
// Keep the basename fallback.
|
|
1265
|
+
}
|
|
1266
|
+
}));
|
|
1267
|
+
return records.filter(record => !empty.has(record.id));
|
|
1268
|
+
}
|
|
1269
|
+
catch {
|
|
1270
|
+
return [];
|
|
1271
|
+
}
|
|
1272
|
+
},
|
|
1273
|
+
setResumeTarget(sessionId) {
|
|
1274
|
+
writeResumeTarget(sessionId);
|
|
1275
|
+
},
|
|
1276
|
+
compact() {
|
|
1277
|
+
// DSH compaction service key: `ctx.compaction` (dsh-compaction's
|
|
1278
|
+
// CompactionEngine; dsh-compaction-basic provides it in the example
|
|
1279
|
+
// leaf). Under agent presets the engine lives in the preset's isolate
|
|
1280
|
+
// realm, invisible from the root context — resolve through the agent's
|
|
1281
|
+
// scope chain first (minimal composes NO compaction: stays unavailable).
|
|
1282
|
+
const compactService = serviceForAgent(ctx, agent, 'compaction');
|
|
1283
|
+
if (!compactService) {
|
|
1284
|
+
state.notify('Compaction unavailable · no compaction service in this leaf', {
|
|
1285
|
+
color: 'warning',
|
|
1286
|
+
});
|
|
1287
|
+
return;
|
|
1288
|
+
}
|
|
1289
|
+
if (state.working) {
|
|
1290
|
+
state.notify('Cannot compact while a turn is running', { color: 'warning' });
|
|
1291
|
+
return;
|
|
1292
|
+
}
|
|
1293
|
+
const signal = new AbortController().signal;
|
|
1294
|
+
state.notify('Compacting conversation…');
|
|
1295
|
+
void compactService
|
|
1296
|
+
.compactNow(agent, signal)
|
|
1297
|
+
.then((result) => {
|
|
1298
|
+
state.notify(result ? 'Conversation compacted' : 'Nothing to compact');
|
|
1299
|
+
})
|
|
1300
|
+
.catch((error) => {
|
|
1301
|
+
state.notify(`Compaction failed · ${error instanceof Error ? error.message : String(error)}`, { color: 'error', timeoutMs: 8000 });
|
|
1302
|
+
});
|
|
1303
|
+
},
|
|
1304
|
+
async runExternalCommand(name, rawInput) {
|
|
1305
|
+
if (!commandService)
|
|
1306
|
+
return undefined;
|
|
1307
|
+
try {
|
|
1308
|
+
const execution = await commandService.execute(agent, `/${name}${rawInput}`, new AbortController().signal);
|
|
1309
|
+
// `undefined` = not registered; a handler error surfaces as its
|
|
1310
|
+
// message so the user sees why the command failed.
|
|
1311
|
+
return execution?.result.text ?? '';
|
|
1312
|
+
}
|
|
1313
|
+
catch (error) {
|
|
1314
|
+
return error instanceof Error ? error.message : String(error);
|
|
1315
|
+
}
|
|
1316
|
+
},
|
|
1317
|
+
pushLocal(title, lines) {
|
|
1318
|
+
state.rows.push({ id: nextRowId++, kind: 'local', text: title });
|
|
1319
|
+
for (const line of lines) {
|
|
1320
|
+
state.rows.push({
|
|
1321
|
+
id: nextRowId++,
|
|
1322
|
+
kind: 'local-output',
|
|
1323
|
+
text: preview(line, LOCAL_OUTPUT_LIMIT),
|
|
1324
|
+
});
|
|
1325
|
+
}
|
|
1326
|
+
state.emit();
|
|
1327
|
+
},
|
|
1328
|
+
mcpStatus() {
|
|
1329
|
+
// MCP tools land on the tool runtime under mcp__<server>__<tool>
|
|
1330
|
+
// public names (dsh-mcp-client's naming contract); group by server.
|
|
1331
|
+
const runtime = ctx.get('tools');
|
|
1332
|
+
const schemas = runtime?.schemas() ?? [];
|
|
1333
|
+
const byServer = new Map();
|
|
1334
|
+
for (const schema of schemas) {
|
|
1335
|
+
const match = schema.name.match(/^mcp__([a-z0-9-]+)__(.+)$/);
|
|
1336
|
+
if (!match)
|
|
1337
|
+
continue;
|
|
1338
|
+
const list = byServer.get(match[1]) ?? [];
|
|
1339
|
+
list.push(match[2]);
|
|
1340
|
+
byServer.set(match[1], list);
|
|
1341
|
+
}
|
|
1342
|
+
if (byServer.size === 0) {
|
|
1343
|
+
return [
|
|
1344
|
+
t('mcp-none-configured'),
|
|
1345
|
+
t('mcp-insert-hint'),
|
|
1346
|
+
' - insert:',
|
|
1347
|
+
' - id: mcp-context7',
|
|
1348
|
+
" name: '@deepseek-ai/dsh-mcp-client'",
|
|
1349
|
+
' config: { transport: stdio, serverName: context7, command: npx, args: ["-y", "@upstash/context7-mcp"] }',
|
|
1350
|
+
t('mcp-readme-hint'),
|
|
1351
|
+
];
|
|
1352
|
+
}
|
|
1353
|
+
const lines = [];
|
|
1354
|
+
for (const [server, tools] of byServer) {
|
|
1355
|
+
lines.push(t('mcp-server-tools', { server, count: tools.length, tools: tools.join(', ') }));
|
|
1356
|
+
}
|
|
1357
|
+
return lines;
|
|
1358
|
+
},
|
|
1359
|
+
exportSession() {
|
|
1360
|
+
// Export from the session log — the authoritative, complete record —
|
|
1361
|
+
// not the bounded transcript window (folded rows keep only previews).
|
|
1362
|
+
const parts = [
|
|
1363
|
+
t('export-title'),
|
|
1364
|
+
'',
|
|
1365
|
+
t('export-time', { time: new Date().toLocaleString() }),
|
|
1366
|
+
t('export-model', { model: state.model }),
|
|
1367
|
+
t('export-session', { id: state.agentId }),
|
|
1368
|
+
t('export-dir', { cwd: state.cwd }),
|
|
1369
|
+
'',
|
|
1370
|
+
];
|
|
1371
|
+
for (const event of agent.session.events) {
|
|
1372
|
+
switch (event.type) {
|
|
1373
|
+
case 'user/message': {
|
|
1374
|
+
if (event.data.source.kind !== 'user')
|
|
1375
|
+
break;
|
|
1376
|
+
// Export what the user SAW: the typed prompt, not the expanded
|
|
1377
|
+
// `@`-mention attachment blocks.
|
|
1378
|
+
const text = firstTextOf(event.data.content);
|
|
1379
|
+
if (text)
|
|
1380
|
+
parts.push(`${t('export-user-section')}\n\n${text}\n`);
|
|
1381
|
+
break;
|
|
1382
|
+
}
|
|
1383
|
+
case 'assistant/message': {
|
|
1384
|
+
const blocks = event.data.message.content;
|
|
1385
|
+
for (const block of blocks) {
|
|
1386
|
+
if (block.type === 'reasoning' && block.text) {
|
|
1387
|
+
parts.push(`${t('export-thinking-section')}\n\n${block.text}\n`);
|
|
1388
|
+
}
|
|
1389
|
+
else if (block.type === 'text' && block.text) {
|
|
1390
|
+
parts.push(`${t('export-assistant-section')}\n\n${block.text}\n`);
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
break;
|
|
1394
|
+
}
|
|
1395
|
+
case 'tool/call': {
|
|
1396
|
+
parts.push(`${t('export-tool-section', { name: event.data.name })}\n\n\`\`\`json\n${event.data.arguments}\n\`\`\`\n`);
|
|
1397
|
+
break;
|
|
1398
|
+
}
|
|
1399
|
+
case 'tool/result': {
|
|
1400
|
+
const block = event.data.message.content[0];
|
|
1401
|
+
// oxlint-disable-next-line typescript/no-unnecessary-condition -- durable session data may not match type
|
|
1402
|
+
if (block.type === 'tool-result') {
|
|
1403
|
+
const text = textOf(block.content);
|
|
1404
|
+
if (text)
|
|
1405
|
+
parts.push(`${t('export-result-section')}\n\n\`\`\`\n${text}\n\`\`\`\n`);
|
|
1406
|
+
}
|
|
1407
|
+
break;
|
|
1408
|
+
}
|
|
1409
|
+
default:
|
|
1410
|
+
break;
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
const fileName = `dsh-tui-export-${Date.now()}.md`;
|
|
1414
|
+
try {
|
|
1415
|
+
const target = join(state.cwd, fileName);
|
|
1416
|
+
writeFileSync(target, parts.join('\n'), 'utf8');
|
|
1417
|
+
return target;
|
|
1418
|
+
}
|
|
1419
|
+
catch {
|
|
1420
|
+
return null;
|
|
1421
|
+
}
|
|
1422
|
+
},
|
|
1423
|
+
initWorkspace() {
|
|
1424
|
+
const target = join(state.cwd, 'AGENTS.md');
|
|
1425
|
+
if (existsSync(target))
|
|
1426
|
+
return 'exists';
|
|
1427
|
+
const template = [
|
|
1428
|
+
'# AGENTS.md',
|
|
1429
|
+
'',
|
|
1430
|
+
t('agentsmd-project'),
|
|
1431
|
+
'',
|
|
1432
|
+
t('agentsmd-project-body'),
|
|
1433
|
+
'',
|
|
1434
|
+
t('agentsmd-conventions'),
|
|
1435
|
+
'',
|
|
1436
|
+
t('agentsmd-convention-read'),
|
|
1437
|
+
t('agentsmd-convention-style'),
|
|
1438
|
+
'',
|
|
1439
|
+
].join('\n');
|
|
1440
|
+
try {
|
|
1441
|
+
writeFileSync(target, template, 'utf8');
|
|
1442
|
+
return target;
|
|
1443
|
+
}
|
|
1444
|
+
catch {
|
|
1445
|
+
return null;
|
|
1446
|
+
}
|
|
1447
|
+
},
|
|
1448
|
+
doctorInfo() {
|
|
1449
|
+
const lines = [];
|
|
1450
|
+
lines.push(`Node ${process.version} · ${process.platform} ${process.arch}`);
|
|
1451
|
+
lines.push(`${t('doctor-api-key', { state: process.env.DEEPSEEK_API_KEY ? t('doctor-key-configured') : t('doctor-key-missing') })}`);
|
|
1452
|
+
lines.push(t('doctor-model', { model: state.model, provider: options.provider }));
|
|
1453
|
+
lines.push(t('doctor-cwd', { cwd: state.cwd }));
|
|
1454
|
+
lines.push(t('doctor-context-window', { window: state.contextWindow ?? t('doctor-unknown') }));
|
|
1455
|
+
lines.push(`${t('doctor-session', { id: state.agentId })}${state.sessionTitle ? ' · ' + state.sessionTitle : ''}`);
|
|
1456
|
+
const userHome = process.env.USERPROFILE ?? homedir();
|
|
1457
|
+
const configCandidates = [
|
|
1458
|
+
join(userHome, '.dsh-cc/cordis.yml'),
|
|
1459
|
+
join(userHome, '.dsh/profiles/dsh-tui/cordis.patch.yml'),
|
|
1460
|
+
];
|
|
1461
|
+
for (const candidate of configCandidates) {
|
|
1462
|
+
lines.push(`${t('doctor-config', { candidate, state: existsSync(candidate) ? '✓' : t('doctor-config-missing') })}`);
|
|
1463
|
+
}
|
|
1464
|
+
const sessionsDir = join(userHome, '.dsh-cc/sessions');
|
|
1465
|
+
lines.push(`${t('doctor-storage', { dir: sessionsDir, state: existsSync(sessionsDir) ? '✓' : t('doctor-storage-uninit') })}`);
|
|
1466
|
+
return lines;
|
|
1467
|
+
},
|
|
1468
|
+
async listSubagents() {
|
|
1469
|
+
const subagents = ctx.get('subagents');
|
|
1470
|
+
if (!subagents)
|
|
1471
|
+
return [t('subagent-not-mounted')];
|
|
1472
|
+
try {
|
|
1473
|
+
const children = await subagents.listChildren(agent.session.id);
|
|
1474
|
+
if (children.length === 0)
|
|
1475
|
+
return [t('subagent-none')];
|
|
1476
|
+
return children.map((child) => {
|
|
1477
|
+
const id = typeof child.id === 'string' ? child.id : (child.id.value ?? '');
|
|
1478
|
+
const label = child.label ? `「${child.label}」` : '';
|
|
1479
|
+
const mode = child.mode === 'continuable' ? t('subagent-resumable') : t('subagent-oneshot');
|
|
1480
|
+
return `${t('subagent-row', { mode, label, activity: child.activity === 'running' ? t('subagent-running') : t('subagent-archived'), id: id.slice(0, 8) })}`;
|
|
1481
|
+
});
|
|
1482
|
+
}
|
|
1483
|
+
catch (error) {
|
|
1484
|
+
return [t('subagent-query-failed', { err: error instanceof Error ? error.message : String(error) })];
|
|
1485
|
+
}
|
|
1486
|
+
},
|
|
1487
|
+
};
|
|
1488
|
+
/**
|
|
1489
|
+
* Assemble the context a fresh conversation for the live agent will load,
|
|
1490
|
+
* for the startup panel: the system prompt (sections + dynamic context +
|
|
1491
|
+
* tools), the workspace instruction files baseline discovery would
|
|
1492
|
+
* inject, and the skill catalog. Runs at boot and on every agent swap;
|
|
1493
|
+
* every source degrades independently, and a total failure leaves the
|
|
1494
|
+
* panel hidden instead of showing a broken snapshot. A snapshot computed
|
|
1495
|
+
* for a previous agent is discarded (swaps rebind `agent` mid-flight).
|
|
1496
|
+
*/
|
|
1497
|
+
const refreshLoadedContext = async () => {
|
|
1498
|
+
const target = agent;
|
|
1499
|
+
const sections = [];
|
|
1500
|
+
const contexts = [];
|
|
1501
|
+
const files = [];
|
|
1502
|
+
const skills = [];
|
|
1503
|
+
const tools = [];
|
|
1504
|
+
try {
|
|
1505
|
+
const systemPrompt = ctx.get('systemPrompt');
|
|
1506
|
+
if (systemPrompt !== undefined) {
|
|
1507
|
+
const assembly = await systemPrompt.assemble(assembleContextFor(target));
|
|
1508
|
+
if (target !== agent)
|
|
1509
|
+
return;
|
|
1510
|
+
// Render each section through the shared strict interpolator with
|
|
1511
|
+
// this assembly's variables (renderPrompt joins; a single-section
|
|
1512
|
+
// assembly renders exactly one section), keeping non-empty results.
|
|
1513
|
+
for (const section of assembly.sections) {
|
|
1514
|
+
const text = renderPrompt({
|
|
1515
|
+
sections: [section],
|
|
1516
|
+
contexts: [],
|
|
1517
|
+
tools: [],
|
|
1518
|
+
variables: assembly.variables,
|
|
1519
|
+
});
|
|
1520
|
+
if (text.length > 0)
|
|
1521
|
+
sections.push({ name: section.name, text });
|
|
1522
|
+
}
|
|
1523
|
+
contexts.push(...renderContextSections(assembly));
|
|
1524
|
+
for (const tool of assembly.tools) {
|
|
1525
|
+
tools.push({ name: tool.name, description: tool.description ?? '' });
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
const discovered = await discoverBaselineInstructionFiles({ cwd: options.cwd });
|
|
1529
|
+
if (target !== agent)
|
|
1530
|
+
return;
|
|
1531
|
+
files.push(...discovered.map(file => ({ displayPath: file.displayPath })));
|
|
1532
|
+
// The skills registry is host-plane but scope-layered: preset rows
|
|
1533
|
+
// (skill-filesystem) register into the preset's layer, so the catalog
|
|
1534
|
+
// must be read through the agent's scope chain (serviceForAgent falls
|
|
1535
|
+
// back to the host context when no roster is mounted).
|
|
1536
|
+
const skillsService = serviceForAgent(ctx, target, 'skills');
|
|
1537
|
+
if (skillsService !== undefined) {
|
|
1538
|
+
const catalog = await skillsService.list({});
|
|
1539
|
+
if (target !== agent)
|
|
1540
|
+
return;
|
|
1541
|
+
skills.push(...catalog.map(skill => ({
|
|
1542
|
+
name: skill.name,
|
|
1543
|
+
description: skill.description,
|
|
1544
|
+
})));
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1547
|
+
catch (error) {
|
|
1548
|
+
ctx.logger.warn('loaded-context snapshot failed: %o', error);
|
|
1549
|
+
return;
|
|
1550
|
+
}
|
|
1551
|
+
state.loadedContext = { sections, contexts, files, skills, tools };
|
|
1552
|
+
state.emit();
|
|
1553
|
+
};
|
|
1554
|
+
/**
|
|
1555
|
+
* Rebuild the merged slash-command list from the registry. Registry
|
|
1556
|
+
* registrations are global or agent-scoped, so this runs on
|
|
1557
|
+
* `commands/change` and again whenever the live agent is swapped
|
|
1558
|
+
* (rewind/resume).
|
|
1559
|
+
*/
|
|
1560
|
+
const refreshCommandList = () => {
|
|
1561
|
+
const merged = [...LOCAL_COMMANDS];
|
|
1562
|
+
if (commandService) {
|
|
1563
|
+
for (const descriptor of commandService.list(agent)) {
|
|
1564
|
+
if (merged.some(command => command.name === descriptor.name))
|
|
1565
|
+
continue;
|
|
1566
|
+
merged.push({
|
|
1567
|
+
name: descriptor.name,
|
|
1568
|
+
description: descriptor.description,
|
|
1569
|
+
tag: descriptor.input?.hint,
|
|
1570
|
+
external: true,
|
|
1571
|
+
});
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
state.commandList = merged;
|
|
1575
|
+
state.emit();
|
|
1576
|
+
};
|
|
1577
|
+
ctx.on('commands/change', refreshCommandList);
|
|
1578
|
+
refreshCommandList();
|
|
1579
|
+
void refreshLoadedContext();
|
|
1580
|
+
let nextRowId = 0;
|
|
1581
|
+
/** The leaf's bash executor (dsh-bash-local in the example leaf) — the DSH
|
|
1582
|
+
* execution seam for local `!` commands and the git status breadcrumb. The
|
|
1583
|
+
* service registers under `ctx.shell` (ShellExecutor; dsh-bash-local and
|
|
1584
|
+
* dsh-pwsh-local are the providers). */
|
|
1585
|
+
const bash = ctx.get('shell');
|
|
1586
|
+
/** Claude Code's `!` mode: run a command on the user's machine and render its
|
|
1587
|
+
* output in the transcript as local rows (never sent to the model). */
|
|
1588
|
+
const runLocalCommand = async (command, includeInContext) => {
|
|
1589
|
+
state.rows.push({ id: nextRowId++, kind: 'local', text: command });
|
|
1590
|
+
state.emit();
|
|
1591
|
+
let output = '(no output)';
|
|
1592
|
+
if (bash) {
|
|
1593
|
+
try {
|
|
1594
|
+
const spec = bash.resolve({
|
|
1595
|
+
command,
|
|
1596
|
+
workdir: state.cwd,
|
|
1597
|
+
timeoutMs: 30000,
|
|
1598
|
+
});
|
|
1599
|
+
const result = await bash.run(spec);
|
|
1600
|
+
output =
|
|
1601
|
+
result.stdout.text.trim() ||
|
|
1602
|
+
result.stderr.text.trim() ||
|
|
1603
|
+
(result.timedOut ? '(timed out)' : '(no output)');
|
|
1604
|
+
}
|
|
1605
|
+
catch (error) {
|
|
1606
|
+
output = error instanceof Error ? error.message : String(error);
|
|
1607
|
+
}
|
|
1608
|
+
}
|
|
1609
|
+
state.rows.push({
|
|
1610
|
+
id: nextRowId++,
|
|
1611
|
+
kind: 'local-output',
|
|
1612
|
+
text: preview(output, LOCAL_OUTPUT_LIMIT),
|
|
1613
|
+
});
|
|
1614
|
+
state.emit();
|
|
1615
|
+
if (includeInContext) {
|
|
1616
|
+
// CC's <bash-stdout> envelope: the model treats the output as the
|
|
1617
|
+
// result of a local command the user just ran.
|
|
1618
|
+
agent.followup(createUserMessage({
|
|
1619
|
+
content: [{
|
|
1620
|
+
type: 'text',
|
|
1621
|
+
text: `<bash-stdout>
|
|
1622
|
+
${output}
|
|
1623
|
+
</bash-stdout>`,
|
|
1624
|
+
}],
|
|
1625
|
+
source: { kind: 'user' },
|
|
1626
|
+
}));
|
|
1627
|
+
}
|
|
1628
|
+
};
|
|
1629
|
+
/** The in-progress assistant text row; `undefined` when no step is streaming. */
|
|
1630
|
+
let streaming;
|
|
1631
|
+
/** The in-progress reasoning row; `undefined` when no reasoning is streaming. */
|
|
1632
|
+
let reasoning;
|
|
1633
|
+
/** Reasoning rows sealed by an assistant/message this turn. They stay
|
|
1634
|
+
* `streaming: true` — expanded in the transcript — until turn/end folds
|
|
1635
|
+
* them (WebUI AssistantMarkdown keepOpen parity: thinking holds open
|
|
1636
|
+
* through the whole in-flight turn, tool-call steps included). */
|
|
1637
|
+
const sealedReasoning = [];
|
|
1638
|
+
/** Wall-clock start of the current reasoning row (durationMs on settle). */
|
|
1639
|
+
let reasoningStart = 0;
|
|
1640
|
+
/** Decode-throughput fold for the current turn. DSH defines one step as
|
|
1641
|
+
* one model call plus its tools; summing only first-token → message spans
|
|
1642
|
+
* excludes tool execution and per-request TTFT from generation speed. */
|
|
1643
|
+
let tpsTurn;
|
|
1644
|
+
let tpsBeforeTurn;
|
|
1645
|
+
let tpsTurnDecodeMs = 0;
|
|
1646
|
+
let tpsTurnDecodeTokens = 0;
|
|
1647
|
+
let tpsTurnSampled = false;
|
|
1648
|
+
let tpsStep;
|
|
1649
|
+
/** Tool cards by callId, so tool/result can settle the running card. */
|
|
1650
|
+
const toolCards = new Map();
|
|
1651
|
+
/** The host-plane tools registry (dsh-tools). Resolved once; absent in
|
|
1652
|
+
* bare embedders — every presenter call soft-fails to undefined and the
|
|
1653
|
+
* card falls back to raw text. */
|
|
1654
|
+
const toolsRegistry = ctx.get('tools');
|
|
1655
|
+
/** Ask the producing tool how its call should render (diff/terminal/…).
|
|
1656
|
+
* Scoped to the live agent so preset-owned tool definitions resolve —
|
|
1657
|
+
* the dsh-host-apiproxy presenter pattern. Unknown tool, unparseable
|
|
1658
|
+
* args, or a throwing presenter all degrade to the plain text card. */
|
|
1659
|
+
const presentCallView = (name, rawArgs) => {
|
|
1660
|
+
try {
|
|
1661
|
+
const tool = toolsRegistry?.get(name, agent);
|
|
1662
|
+
if (tool?.presentCall === undefined)
|
|
1663
|
+
return undefined;
|
|
1664
|
+
return tool.presentCall(JSON.parse(rawArgs));
|
|
1665
|
+
}
|
|
1666
|
+
catch {
|
|
1667
|
+
return undefined;
|
|
1668
|
+
}
|
|
1669
|
+
};
|
|
1670
|
+
/** Same for the settled result; `meta` is the tool-private presentation
|
|
1671
|
+
* payload the tool attached to its tool/result event (dsh-tool-fs reads
|
|
1672
|
+
* its result-time contextual diff back from here). */
|
|
1673
|
+
const presentResultView = (name, rawArgs, data) => {
|
|
1674
|
+
try {
|
|
1675
|
+
const tool = toolsRegistry?.get(name, agent);
|
|
1676
|
+
if (tool?.presentResult === undefined)
|
|
1677
|
+
return undefined;
|
|
1678
|
+
const block = data.message.content[0];
|
|
1679
|
+
// oxlint-disable-next-line typescript/no-unnecessary-condition -- durable session data may not match type
|
|
1680
|
+
const content = block !== undefined && block.type === 'tool-result' ? block.content : [];
|
|
1681
|
+
return tool.presentResult(JSON.parse(rawArgs), {
|
|
1682
|
+
content,
|
|
1683
|
+
isError: block?.isError === true,
|
|
1684
|
+
...(data.meta !== undefined ? { meta: data.meta } : {}),
|
|
1685
|
+
});
|
|
1686
|
+
}
|
|
1687
|
+
catch {
|
|
1688
|
+
return undefined;
|
|
1689
|
+
}
|
|
1690
|
+
};
|
|
1691
|
+
// ContentBlockMap is merge-extensible: plugin-added block types are
|
|
1692
|
+
// silently skipped (v1 renders text blocks only) — never crashes.
|
|
1693
|
+
const textOf = (content) => (content ?? []).map(block => (block.type === 'text' ? block.text : '')).join('').trim();
|
|
1694
|
+
/**
|
|
1695
|
+
* Transcript-facing text of a user message: the FIRST text block only.
|
|
1696
|
+
* `@`-mention attachments (issue #15) ride as later blocks — model-facing
|
|
1697
|
+
* only — so joining every block would dump file contents into the bubble,
|
|
1698
|
+
* the sticky header, and session titles.
|
|
1699
|
+
*/
|
|
1700
|
+
const firstTextOf = (content) => (content ?? []).find(block => block.type === 'text')?.text.trim() ?? '';
|
|
1701
|
+
const ensureStreaming = (seq) => {
|
|
1702
|
+
if (streaming === undefined) {
|
|
1703
|
+
streaming = { id: nextRowId, kind: 'assistant', text: '', streaming: true, ...seq !== undefined ? { seq } : {} };
|
|
1704
|
+
nextRowId += 1;
|
|
1705
|
+
state.rows.push(streaming);
|
|
1706
|
+
}
|
|
1707
|
+
return streaming;
|
|
1708
|
+
};
|
|
1709
|
+
const ensureReasoning = (seq) => {
|
|
1710
|
+
if (reasoning === undefined) {
|
|
1711
|
+
reasoningStart = Date.now();
|
|
1712
|
+
reasoning = { id: nextRowId, kind: 'reasoning', text: '', streaming: true, ...seq !== undefined ? { seq } : {} };
|
|
1713
|
+
nextRowId += 1;
|
|
1714
|
+
state.rows.push(reasoning);
|
|
1715
|
+
logForDebugging('thinking: reasoning row open (expanded)');
|
|
1716
|
+
}
|
|
1717
|
+
return reasoning;
|
|
1718
|
+
};
|
|
1719
|
+
const settleStreaming = () => {
|
|
1720
|
+
if (streaming !== undefined)
|
|
1721
|
+
streaming.streaming = false;
|
|
1722
|
+
streaming = undefined;
|
|
1723
|
+
const folded = sealedReasoning.length + (reasoning !== undefined ? 1 : 0);
|
|
1724
|
+
for (const row of sealedReasoning)
|
|
1725
|
+
row.streaming = false;
|
|
1726
|
+
sealedReasoning.length = 0;
|
|
1727
|
+
if (reasoning !== undefined) {
|
|
1728
|
+
reasoning.streaming = false;
|
|
1729
|
+
reasoning.durationMs = Math.max(0, Date.now() - reasoningStart);
|
|
1730
|
+
}
|
|
1731
|
+
reasoning = undefined;
|
|
1732
|
+
if (folded > 0)
|
|
1733
|
+
logForDebugging(`thinking: folded ${folded} reasoning row(s) at turn settle`);
|
|
1734
|
+
};
|
|
1735
|
+
/** Recompute the spinner phase from live row/tool state. */
|
|
1736
|
+
const updateSpinnerMode = () => {
|
|
1737
|
+
if (state.activeToolCount > 0) {
|
|
1738
|
+
state.spinnerMode = 'tool-use';
|
|
1739
|
+
}
|
|
1740
|
+
else if (reasoning !== undefined) {
|
|
1741
|
+
// Only LIVE reasoning counts — sealed rows stay streaming=true for
|
|
1742
|
+
// transcript expansion until turn/end but the model is past thinking.
|
|
1743
|
+
state.spinnerMode = 'thinking';
|
|
1744
|
+
}
|
|
1745
|
+
else if (streaming !== undefined) {
|
|
1746
|
+
state.spinnerMode = 'responding';
|
|
1747
|
+
}
|
|
1748
|
+
else {
|
|
1749
|
+
state.spinnerMode = 'requesting';
|
|
1750
|
+
}
|
|
1751
|
+
};
|
|
1752
|
+
/**
|
|
1753
|
+
* Fold one goal-sourced message into the channel's goal projection.
|
|
1754
|
+
* Round-zero goal messages carry the full durable snapshot (or a clear
|
|
1755
|
+
* tombstone) in their source; positive-round messages are admitted
|
|
1756
|
+
* continuation prompts that only advance the rounds counter.
|
|
1757
|
+
*/
|
|
1758
|
+
const applyGoalEvent = (event) => {
|
|
1759
|
+
const source = event.data.source;
|
|
1760
|
+
if (source.round > 0) {
|
|
1761
|
+
// Admitted continuation round — the snapshot itself is unchanged.
|
|
1762
|
+
if (state.goal !== undefined) {
|
|
1763
|
+
state.goal = {
|
|
1764
|
+
...state.goal,
|
|
1765
|
+
roundsStarted: Math.max(state.goal.roundsStarted, source.round),
|
|
1766
|
+
};
|
|
1767
|
+
}
|
|
1768
|
+
return;
|
|
1769
|
+
}
|
|
1770
|
+
const change = source.change;
|
|
1771
|
+
// oxlint-disable-next-line typescript/no-unnecessary-condition -- durable replay data may not match the static type
|
|
1772
|
+
if (change === undefined || change.kind !== 'goal/change')
|
|
1773
|
+
return;
|
|
1774
|
+
if (change.operation === 'clear') {
|
|
1775
|
+
state.goal = undefined;
|
|
1776
|
+
}
|
|
1777
|
+
else if (change.goal !== undefined) {
|
|
1778
|
+
state.goal = {
|
|
1779
|
+
...change.goal,
|
|
1780
|
+
roundsStarted: change.roundsStarted ?? state.goal?.roundsStarted ?? 0,
|
|
1781
|
+
};
|
|
1782
|
+
}
|
|
1783
|
+
};
|
|
1784
|
+
const renderEvent = (event) => {
|
|
1785
|
+
switch (event.type) {
|
|
1786
|
+
case 'user/message': {
|
|
1787
|
+
// Compaction checkpoint: `source = { kind: 'plugin', plugin:
|
|
1788
|
+
// 'compact' }` (dsh-compact's COMPACT_CHECKPOINT_SOURCE). CC shows
|
|
1789
|
+
// the framed summary after /compact; render it as a Divider title +
|
|
1790
|
+
// a summary row that defaults folded (`compact` kind) instead of
|
|
1791
|
+
// skipping it like other injected context.
|
|
1792
|
+
if (event.data.source.kind === 'plugin' &&
|
|
1793
|
+
event.data.source.plugin === 'compact') {
|
|
1794
|
+
const summary = textOf(event.data.content);
|
|
1795
|
+
state.rows.push({ id: nextRowId, kind: 'notice', text: 'Conversation compacted' });
|
|
1796
|
+
nextRowId += 1;
|
|
1797
|
+
if (summary) {
|
|
1798
|
+
state.rows.push({ id: nextRowId, kind: 'compact', text: summary });
|
|
1799
|
+
nextRowId += 1;
|
|
1800
|
+
}
|
|
1801
|
+
// The surface replace drops the whole pre-compact history: reset
|
|
1802
|
+
// the context accounting NOW so the status bar (ctx bar, tokens,
|
|
1803
|
+
// context-low warning) drops immediately instead of waiting for
|
|
1804
|
+
// the next request's usage event.
|
|
1805
|
+
const removed = state.contextSegments.prompt +
|
|
1806
|
+
state.contextSegments.assistant +
|
|
1807
|
+
state.contextSegments.thinking +
|
|
1808
|
+
state.contextSegments.tools;
|
|
1809
|
+
const summaryTokens = estimateTokens(summary);
|
|
1810
|
+
state.tokens.input = Math.max(0, state.tokens.input - removed) + summaryTokens;
|
|
1811
|
+
state.contextSegments = {
|
|
1812
|
+
system: state.contextSegments.system,
|
|
1813
|
+
prompt: summaryTokens,
|
|
1814
|
+
assistant: 0,
|
|
1815
|
+
thinking: 0,
|
|
1816
|
+
tools: 0,
|
|
1817
|
+
};
|
|
1818
|
+
state.lastUsage = {
|
|
1819
|
+
input: state.contextSegments.system + summaryTokens,
|
|
1820
|
+
output: 0,
|
|
1821
|
+
cacheRead: 0,
|
|
1822
|
+
cacheWrite: 0,
|
|
1823
|
+
};
|
|
1824
|
+
contextWarned = false;
|
|
1825
|
+
break;
|
|
1826
|
+
}
|
|
1827
|
+
// Same-session goal domain: round-zero goal-sourced messages carry
|
|
1828
|
+
// the durable goal snapshot (or clear tombstone) in their source.
|
|
1829
|
+
// They are not transcript bubbles — they drive the goal panel's
|
|
1830
|
+
// live projection (replayed on resume/rewind like every other event).
|
|
1831
|
+
if (event.data.source.kind === 'goal') {
|
|
1832
|
+
applyGoalEvent(event);
|
|
1833
|
+
break;
|
|
1834
|
+
}
|
|
1835
|
+
// Injected context (plugin/skill source) is not a human bubble; v1
|
|
1836
|
+
// renders direct human prompts only.
|
|
1837
|
+
if (event.data.source.kind !== 'user')
|
|
1838
|
+
break;
|
|
1839
|
+
const text = firstTextOf(event.data.content);
|
|
1840
|
+
if (text) {
|
|
1841
|
+
state.rows.push({ id: nextRowId, kind: 'user', text, seq: event.seq });
|
|
1842
|
+
state.lastUserText = text;
|
|
1843
|
+
// The context estimate counts everything sent to the model —
|
|
1844
|
+
// typed text AND the `@`-mention attachment blocks.
|
|
1845
|
+
state.contextSegments.prompt += estimateTokens(textOf(event.data.content));
|
|
1846
|
+
nextRowId += 1;
|
|
1847
|
+
}
|
|
1848
|
+
break;
|
|
1849
|
+
}
|
|
1850
|
+
case 'step/start': {
|
|
1851
|
+
if (tpsTurn === event.data.turn) {
|
|
1852
|
+
tpsStep = {
|
|
1853
|
+
turn: event.data.turn,
|
|
1854
|
+
step: event.data.step,
|
|
1855
|
+
firstTokenTime: undefined,
|
|
1856
|
+
outputChars: 0,
|
|
1857
|
+
};
|
|
1858
|
+
}
|
|
1859
|
+
break;
|
|
1860
|
+
}
|
|
1861
|
+
case 'assistant/chunk': {
|
|
1862
|
+
const chunk = event.data.chunk;
|
|
1863
|
+
if (chunk.type === 'text-delta') {
|
|
1864
|
+
if (chunk.text) {
|
|
1865
|
+
ensureStreaming(event.seq).text += chunk.text;
|
|
1866
|
+
state.responseChars += chunk.text.length;
|
|
1867
|
+
}
|
|
1868
|
+
}
|
|
1869
|
+
else if (chunk.type === 'reasoning-delta') {
|
|
1870
|
+
if (chunk.text)
|
|
1871
|
+
ensureReasoning(event.seq).text += chunk.text;
|
|
1872
|
+
}
|
|
1873
|
+
const step = tpsStep;
|
|
1874
|
+
if (step !== undefined &&
|
|
1875
|
+
step.turn === event.data.turn &&
|
|
1876
|
+
step.step === event.data.step &&
|
|
1877
|
+
isTokenDelta(chunk)) {
|
|
1878
|
+
step.firstTokenTime ??= event.time;
|
|
1879
|
+
step.outputChars += tokenDeltaChars(chunk);
|
|
1880
|
+
const elapsedMs = Math.max(0, event.time - step.firstTokenTime);
|
|
1881
|
+
if (elapsedMs > 500) {
|
|
1882
|
+
const decodeMs = tpsTurnDecodeMs + elapsedMs;
|
|
1883
|
+
const outputTokens = tpsTurnDecodeTokens + Math.ceil(step.outputChars / 4);
|
|
1884
|
+
state.tps = outputTokens / (decodeMs / 1000);
|
|
1885
|
+
}
|
|
1886
|
+
}
|
|
1887
|
+
updateSpinnerMode();
|
|
1888
|
+
break;
|
|
1889
|
+
}
|
|
1890
|
+
case 'assistant/message': {
|
|
1891
|
+
const row = ensureStreaming(event.seq);
|
|
1892
|
+
row.time = event.time;
|
|
1893
|
+
const text = textOf(event.data.message.content);
|
|
1894
|
+
if (text)
|
|
1895
|
+
row.text = text;
|
|
1896
|
+
row.streaming = false;
|
|
1897
|
+
streaming = undefined;
|
|
1898
|
+
if (reasoning !== undefined) {
|
|
1899
|
+
// Seal, don't fold: the per-step duration settles here, but the
|
|
1900
|
+
// row keeps streaming=true (expanded) until turn/end — WebUI
|
|
1901
|
+
// keepOpen parity. The next step's reasoning opens a fresh row.
|
|
1902
|
+
reasoning.durationMs = Math.max(0, Date.now() - reasoningStart);
|
|
1903
|
+
sealedReasoning.push(reasoning);
|
|
1904
|
+
logForDebugging(`thinking: step sealed (${reasoning.durationMs}ms), expanded until turn/end`);
|
|
1905
|
+
}
|
|
1906
|
+
reasoning = undefined;
|
|
1907
|
+
updateSpinnerMode();
|
|
1908
|
+
const usage = event.data.usage;
|
|
1909
|
+
if (usage !== undefined) {
|
|
1910
|
+
// oxlint-disable-next-line typescript/no-unnecessary-condition -- durable replay data may lack tokens
|
|
1911
|
+
state.tokens.input += usage.inputTokens ?? 0;
|
|
1912
|
+
// oxlint-disable-next-line typescript/no-unnecessary-condition -- durable replay data may lack tokens
|
|
1913
|
+
state.tokens.output += usage.outputTokens ?? 0;
|
|
1914
|
+
// The most recent request's usage describes the CURRENT context:
|
|
1915
|
+
// input (uncached) + cache hits all occupy the window. Cache hits
|
|
1916
|
+
// also drive the status-line `cache N` readout.
|
|
1917
|
+
state.lastUsage = {
|
|
1918
|
+
// oxlint-disable-next-line typescript/no-unnecessary-condition -- durable replay data may lack tokens
|
|
1919
|
+
input: usage.inputTokens ?? 0,
|
|
1920
|
+
// oxlint-disable-next-line typescript/no-unnecessary-condition -- durable replay data may lack tokens
|
|
1921
|
+
output: usage.outputTokens ?? 0,
|
|
1922
|
+
cacheRead: usage.cacheReadTokens ?? 0,
|
|
1923
|
+
cacheWrite: usage.cacheWriteTokens ?? 0,
|
|
1924
|
+
};
|
|
1925
|
+
}
|
|
1926
|
+
const tpsMessageStep = tpsStep;
|
|
1927
|
+
if (tpsTurn === event.data.turn &&
|
|
1928
|
+
tpsMessageStep !== undefined &&
|
|
1929
|
+
tpsMessageStep.turn === event.data.turn &&
|
|
1930
|
+
tpsMessageStep.step === event.data.step &&
|
|
1931
|
+
tpsMessageStep.firstTokenTime !== undefined) {
|
|
1932
|
+
const outputTokens = usageOutputTokens(usage)
|
|
1933
|
+
?? (tpsMessageStep.outputChars > 0
|
|
1934
|
+
? Math.ceil(tpsMessageStep.outputChars / 4)
|
|
1935
|
+
: undefined);
|
|
1936
|
+
if (outputTokens !== undefined) {
|
|
1937
|
+
tpsTurnDecodeMs += Math.max(0, event.time - tpsMessageStep.firstTokenTime);
|
|
1938
|
+
tpsTurnDecodeTokens += outputTokens;
|
|
1939
|
+
tpsTurnSampled = true;
|
|
1940
|
+
if (tpsTurnDecodeMs > 0) {
|
|
1941
|
+
state.tps = tpsTurnDecodeTokens / (tpsTurnDecodeMs / 1000);
|
|
1942
|
+
}
|
|
1943
|
+
}
|
|
1944
|
+
}
|
|
1945
|
+
if (tpsMessageStep !== undefined &&
|
|
1946
|
+
tpsMessageStep.turn === event.data.turn &&
|
|
1947
|
+
tpsMessageStep.step === event.data.step) {
|
|
1948
|
+
tpsStep = undefined;
|
|
1949
|
+
}
|
|
1950
|
+
// Context-bar segmentation (pi-nano-context style): assistant text
|
|
1951
|
+
// and tool calls in the assistant segment, thinking separately.
|
|
1952
|
+
for (const block of event.data.message.content) {
|
|
1953
|
+
if (block.type === 'text' && block.text) {
|
|
1954
|
+
state.contextSegments.assistant += estimateTokens(block.text);
|
|
1955
|
+
}
|
|
1956
|
+
else if (block.type === 'reasoning' && block.text) {
|
|
1957
|
+
state.contextSegments.thinking += estimateTokens(block.text);
|
|
1958
|
+
}
|
|
1959
|
+
}
|
|
1960
|
+
break;
|
|
1961
|
+
}
|
|
1962
|
+
case 'tool/call': {
|
|
1963
|
+
// The ask-user-question tool renders as the interactive questionnaire
|
|
1964
|
+
// panel (DSH user-interaction seam), not as a tool card: the model is
|
|
1965
|
+
// parked waiting for the human, so no running card, no active-tool
|
|
1966
|
+
// spinner, no args noise in the transcript. The Q&A summary is pushed
|
|
1967
|
+
// by the TUI once the batch is answered; tool/result for a call with
|
|
1968
|
+
// no card is a no-op below.
|
|
1969
|
+
if (event.data.name === 'ask_user_question')
|
|
1970
|
+
break;
|
|
1971
|
+
const card = {
|
|
1972
|
+
id: nextRowId,
|
|
1973
|
+
kind: 'tool',
|
|
1974
|
+
text: '',
|
|
1975
|
+
seq: event.seq,
|
|
1976
|
+
tool: {
|
|
1977
|
+
callId: event.data.callId,
|
|
1978
|
+
name: event.data.name,
|
|
1979
|
+
argsText: preview(event.data.arguments, ARGS_PREVIEW_LIMIT),
|
|
1980
|
+
argsFull: event.data.arguments,
|
|
1981
|
+
status: 'running',
|
|
1982
|
+
callView: presentCallView(event.data.name, event.data.arguments),
|
|
1983
|
+
startedAt: Date.now(),
|
|
1984
|
+
},
|
|
1985
|
+
};
|
|
1986
|
+
nextRowId += 1;
|
|
1987
|
+
toolCards.set(event.data.callId, card);
|
|
1988
|
+
state.rows.push(card);
|
|
1989
|
+
state.activeToolCount += 1;
|
|
1990
|
+
state.contextSegments.assistant += estimateTokens(`${event.data.name}${event.data.arguments}`);
|
|
1991
|
+
updateSpinnerMode();
|
|
1992
|
+
break;
|
|
1993
|
+
}
|
|
1994
|
+
case 'tool/result': {
|
|
1995
|
+
const card = toolCards.get(event.data.message.source.callId);
|
|
1996
|
+
if (card !== undefined && card.tool !== undefined) {
|
|
1997
|
+
card.tool.durationMs = Math.max(0, Date.now() - card.tool.startedAt);
|
|
1998
|
+
const failure = event.data.error;
|
|
1999
|
+
if (failure !== undefined) {
|
|
2000
|
+
card.tool.status = 'error';
|
|
2001
|
+
const errorText = `${failure.name}: ${failure.code}`;
|
|
2002
|
+
card.tool.errorText = errorText;
|
|
2003
|
+
state.contextSegments.tools += estimateTokens(errorText);
|
|
2004
|
+
}
|
|
2005
|
+
else {
|
|
2006
|
+
card.tool.status = 'ok';
|
|
2007
|
+
const block = event.data.message.content[0];
|
|
2008
|
+
// oxlint-disable-next-line typescript/no-unnecessary-condition -- durable session data may not match type
|
|
2009
|
+
const result = block !== undefined && block.type === 'tool-result' ? textOf(block.content) : '';
|
|
2010
|
+
card.tool.resultFull = result || undefined;
|
|
2011
|
+
card.tool.resultText = result ? preview(result, RESULT_PREVIEW_LIMIT) : undefined;
|
|
2012
|
+
// The tool's own settled-state view (applied diff, terminal
|
|
2013
|
+
// output, read content…) wins over the raw text body. argsFull
|
|
2014
|
+
// pairs the args: live cards are never folded, so it is intact.
|
|
2015
|
+
card.tool.resultView = presentResultView(card.tool.name, card.tool.argsFull ?? '', event.data);
|
|
2016
|
+
state.contextSegments.tools += estimateTokens(result);
|
|
2017
|
+
}
|
|
2018
|
+
state.activeToolCount = Math.max(0, state.activeToolCount - 1);
|
|
2019
|
+
// The card is settled: no later event looks it up by callId, so
|
|
2020
|
+
// drop the index entry. The card itself stays in state.rows
|
|
2021
|
+
// (bounded by MAX_ROWS + foldRows, which also drops the full
|
|
2022
|
+
// args/result payloads of folded cards).
|
|
2023
|
+
toolCards.delete(event.data.message.source.callId);
|
|
2024
|
+
updateSpinnerMode();
|
|
2025
|
+
}
|
|
2026
|
+
break;
|
|
2027
|
+
}
|
|
2028
|
+
case 'step/end': {
|
|
2029
|
+
if (tpsStep !== undefined &&
|
|
2030
|
+
tpsStep.turn === event.data.turn &&
|
|
2031
|
+
tpsStep.step === event.data.step) {
|
|
2032
|
+
tpsStep = undefined;
|
|
2033
|
+
}
|
|
2034
|
+
break;
|
|
2035
|
+
}
|
|
2036
|
+
case 'turn/start': {
|
|
2037
|
+
state.working = true;
|
|
2038
|
+
state.turnStart = Date.now();
|
|
2039
|
+
state.responseChars = 0;
|
|
2040
|
+
state.spinnerMode = 'requesting';
|
|
2041
|
+
// Keep the prior turn visible until this turn produces a measurable
|
|
2042
|
+
// decode span, while starting a fresh weighted step fold.
|
|
2043
|
+
tpsBeforeTurn = state.tps;
|
|
2044
|
+
tpsTurn = event.data.turn;
|
|
2045
|
+
tpsTurnDecodeMs = 0;
|
|
2046
|
+
tpsTurnDecodeTokens = 0;
|
|
2047
|
+
tpsTurnSampled = false;
|
|
2048
|
+
tpsStep = undefined;
|
|
2049
|
+
break;
|
|
2050
|
+
}
|
|
2051
|
+
case 'turn/end': {
|
|
2052
|
+
settleStreaming();
|
|
2053
|
+
state.working = false;
|
|
2054
|
+
state.activeToolCount = 0;
|
|
2055
|
+
if (tpsTurn !== undefined && tpsTurn === event.data.turn) {
|
|
2056
|
+
if (tpsTurnSampled && tpsTurnDecodeMs > 0) {
|
|
2057
|
+
const turnTps = tpsTurnDecodeTokens / (tpsTurnDecodeMs / 1000);
|
|
2058
|
+
state.tps = turnTps;
|
|
2059
|
+
state.tpsSamples.push({ tps: turnTps, at: event.time });
|
|
2060
|
+
if (state.tpsSamples.length > 500)
|
|
2061
|
+
state.tpsSamples.shift();
|
|
2062
|
+
}
|
|
2063
|
+
else {
|
|
2064
|
+
// Do not leave a chars/4 live estimate behind when no completed
|
|
2065
|
+
// decode sample exists for this turn.
|
|
2066
|
+
state.tps = tpsBeforeTurn;
|
|
2067
|
+
}
|
|
2068
|
+
tpsTurn = undefined;
|
|
2069
|
+
tpsStep = undefined;
|
|
2070
|
+
tpsTurnDecodeMs = 0;
|
|
2071
|
+
tpsTurnDecodeTokens = 0;
|
|
2072
|
+
tpsTurnSampled = false;
|
|
2073
|
+
}
|
|
2074
|
+
const reason = event.data.reason;
|
|
2075
|
+
if (reason.kind === 'completed') {
|
|
2076
|
+
checkContextWarning();
|
|
2077
|
+
break;
|
|
2078
|
+
}
|
|
2079
|
+
if (reason.kind === 'aborted' || reason.kind === 'interrupted') {
|
|
2080
|
+
// `Agent.cancel()` closes the turn as `aborted`; `interrupted`
|
|
2081
|
+
// only appears for crash-orphaned turns. Claude Code renders both
|
|
2082
|
+
// user-interruption paths as a distinct dim row.
|
|
2083
|
+
state.rows.push({
|
|
2084
|
+
id: nextRowId,
|
|
2085
|
+
kind: 'interrupt',
|
|
2086
|
+
text: 'Interrupted · What should Claude do instead?',
|
|
2087
|
+
});
|
|
2088
|
+
nextRowId += 1;
|
|
2089
|
+
break;
|
|
2090
|
+
}
|
|
2091
|
+
const detail = reason.kind === 'error' ? reason.error.message : '';
|
|
2092
|
+
state.rows.push({ id: nextRowId, kind: 'notice', text: `turn ${reason.kind}${detail ? ` · ${detail}` : ''}` });
|
|
2093
|
+
nextRowId += 1;
|
|
2094
|
+
state.notify(`Turn ${reason.kind}${detail ? ` · ${detail}` : ''}`, { color: 'error', timeoutMs: 8000 });
|
|
2095
|
+
break;
|
|
2096
|
+
}
|
|
2097
|
+
case 'request/context':
|
|
2098
|
+
// Adapter-advertised context capacity; drives the context-low
|
|
2099
|
+
// warning (CC's TokenWarning) when the route reports one.
|
|
2100
|
+
if (event.data.contextWindow !== undefined) {
|
|
2101
|
+
state.contextWindow = event.data.contextWindow;
|
|
2102
|
+
}
|
|
2103
|
+
break;
|
|
2104
|
+
case 'request/header': {
|
|
2105
|
+
// Reasoning effort readout (status line): the header carries the
|
|
2106
|
+
// conversation's call config (provider/model/effort/sampling). The
|
|
2107
|
+
// system prompt text seeds the context bar's system segment.
|
|
2108
|
+
// oxlint-disable-next-line typescript/no-unnecessary-condition -- durable session data may lack header config
|
|
2109
|
+
const effort = event.data.header.config?.reasoningEffort;
|
|
2110
|
+
if (typeof effort === 'string') {
|
|
2111
|
+
state.reasoningEffort = effort;
|
|
2112
|
+
}
|
|
2113
|
+
if (typeof event.data.header.system === 'string') {
|
|
2114
|
+
state.contextSegments.system = estimateTokens(event.data.header.system);
|
|
2115
|
+
}
|
|
2116
|
+
break;
|
|
2117
|
+
}
|
|
2118
|
+
case 'session/title':
|
|
2119
|
+
state.sessionTitle = event.data.title;
|
|
2120
|
+
break;
|
|
2121
|
+
case 'todo/write':
|
|
2122
|
+
// Whole-list snapshot — latest write wins; log-only UI state.
|
|
2123
|
+
state.todos = event.data.todos;
|
|
2124
|
+
break;
|
|
2125
|
+
default:
|
|
2126
|
+
// Logged preset switch (blank sessions only, issue #8): a transcript
|
|
2127
|
+
// marker so a replayed log shows which composition produced the
|
|
2128
|
+
// turns after it. Not in dsh-session's typed union — matched here by
|
|
2129
|
+
// name, the way `activity/status` is consumed in bindAgent.
|
|
2130
|
+
if (event.type === 'agent-preset/selected') {
|
|
2131
|
+
const data = event.data;
|
|
2132
|
+
state.rows.push({
|
|
2133
|
+
id: nextRowId,
|
|
2134
|
+
kind: 'notice',
|
|
2135
|
+
text: t('agent-preset-switched', { preset: data.agentPreset ?? 'unknown' }),
|
|
2136
|
+
});
|
|
2137
|
+
nextRowId += 1;
|
|
2138
|
+
}
|
|
2139
|
+
break;
|
|
2140
|
+
}
|
|
2141
|
+
};
|
|
2142
|
+
// Replay the durable transcript first, then follow live events.
|
|
2143
|
+
for (const event of coalesceReplayEvents(agent.session.events))
|
|
2144
|
+
renderEvent(event);
|
|
2145
|
+
settleStreaming();
|
|
2146
|
+
// Attached to an idle agent: any replayed turn/start belongs to a previous
|
|
2147
|
+
// session run, so the spinner must not come up on boot.
|
|
2148
|
+
state.working = false;
|
|
2149
|
+
state.status = agent.status;
|
|
2150
|
+
state.emit();
|
|
2151
|
+
// Live subscription list, rebound to a fresh agent by rewindTo.
|
|
2152
|
+
let agentSubscriptions = [];
|
|
2153
|
+
const bindAgent = () => {
|
|
2154
|
+
for (const dispose of agentSubscriptions)
|
|
2155
|
+
dispose();
|
|
2156
|
+
// Re-couple the channel-owned model selection to the new agent's
|
|
2157
|
+
// assembly/request waterfalls, then re-apply the persisted effort when
|
|
2158
|
+
// this agent's route offers it (dsh-agent installModelSelection).
|
|
2159
|
+
selection.current = undefined;
|
|
2160
|
+
selection.assembled = undefined;
|
|
2161
|
+
void applyPreferredEffort();
|
|
2162
|
+
agentSubscriptions = [
|
|
2163
|
+
installModelSelection(agent.ctx, selection),
|
|
2164
|
+
ctx.on('agent/status', ({ agent: subject, status }) => {
|
|
2165
|
+
if (subject !== agent)
|
|
2166
|
+
return;
|
|
2167
|
+
state.status = status;
|
|
2168
|
+
state.emit();
|
|
2169
|
+
}),
|
|
2170
|
+
ctx.on('agent/disposed', ({ agent: subject }) => {
|
|
2171
|
+
if (subject !== agent)
|
|
2172
|
+
return;
|
|
2173
|
+
state.status = 'disposed';
|
|
2174
|
+
state.emit();
|
|
2175
|
+
}),
|
|
2176
|
+
// Pending delivery is driven by the agent inbox: a claimed message
|
|
2177
|
+
// has landed in a turn (steer → step boundary, followup → next turn);
|
|
2178
|
+
// a discarded one was dropped by a cancel or withdrawn via Alt+Up.
|
|
2179
|
+
// Retire it from the preview. Official dsh-agent rc.6 emits these as
|
|
2180
|
+
// single-payload notifications `{ agent, message }`; `inserted` is not
|
|
2181
|
+
// handled here because trackPending already registered the preview
|
|
2182
|
+
// synchronously at submit time.
|
|
2183
|
+
(() => {
|
|
2184
|
+
const retirePending = (payload) => {
|
|
2185
|
+
if (payload.agent !== agent)
|
|
2186
|
+
return;
|
|
2187
|
+
const messageId = payload.message?.id;
|
|
2188
|
+
if (typeof messageId !== 'string')
|
|
2189
|
+
return;
|
|
2190
|
+
const before = state.pending.length;
|
|
2191
|
+
state.pending = state.pending.filter(item => item.id !== messageId);
|
|
2192
|
+
if (state.pending.length !== before)
|
|
2193
|
+
state.emit();
|
|
2194
|
+
};
|
|
2195
|
+
const disposers = [];
|
|
2196
|
+
for (const event of ['agent/inbox/claimed', 'agent/inbox/discarded']) {
|
|
2197
|
+
disposers.push(ctx.on(event, retirePending));
|
|
2198
|
+
}
|
|
2199
|
+
return () => {
|
|
2200
|
+
for (const dispose of disposers)
|
|
2201
|
+
dispose();
|
|
2202
|
+
};
|
|
2203
|
+
})(),
|
|
2204
|
+
ctx.on('session/event', (session, event) => {
|
|
2205
|
+
if (session !== agent.session)
|
|
2206
|
+
return;
|
|
2207
|
+
// Live working-activity snapshot (log-only event, appended by
|
|
2208
|
+
// dsh-working-activity for UI consumers). Consumed here — NOT in
|
|
2209
|
+
// renderEvent — so replayed history never resurrects a stale line
|
|
2210
|
+
// (the renderEvent switch's default arm ignores it on replay).
|
|
2211
|
+
if (options.activity !== false &&
|
|
2212
|
+
event.type === 'activity/status') {
|
|
2213
|
+
const data = event.data;
|
|
2214
|
+
state.workingActivity = {
|
|
2215
|
+
phase: data.phase,
|
|
2216
|
+
line: data.line,
|
|
2217
|
+
toolCount: data.toolCount ?? 0,
|
|
2218
|
+
turnElapsedMs: data.turnElapsedMs ?? 0,
|
|
2219
|
+
...(data.label === undefined ? {} : { label: data.label }),
|
|
2220
|
+
...(data.detail === undefined ? {} : { detail: data.detail }),
|
|
2221
|
+
...(data.phrase === undefined ? {} : { phrase: data.phrase }),
|
|
2222
|
+
};
|
|
2223
|
+
state.emit();
|
|
2224
|
+
return;
|
|
2225
|
+
}
|
|
2226
|
+
renderEvent(event);
|
|
2227
|
+
// Streaming deltas (one event per token) take the frame-aligned
|
|
2228
|
+
// path; every other event keeps synchronous notification.
|
|
2229
|
+
if (event.type === 'assistant/chunk')
|
|
2230
|
+
state.emitStream();
|
|
2231
|
+
else
|
|
2232
|
+
state.emit();
|
|
2233
|
+
}),
|
|
2234
|
+
];
|
|
2235
|
+
};
|
|
2236
|
+
bindAgent();
|
|
2237
|
+
// Statusline breadcrumb: current git branch of the session cwd (best-effort).
|
|
2238
|
+
if (bash) {
|
|
2239
|
+
void bash
|
|
2240
|
+
.run(bash.resolve({
|
|
2241
|
+
command: 'git branch --show-current',
|
|
2242
|
+
workdir: options.cwd,
|
|
2243
|
+
timeoutMs: 3000,
|
|
2244
|
+
}))
|
|
2245
|
+
.then((result) => {
|
|
2246
|
+
const branch = result.stdout.text.trim();
|
|
2247
|
+
if (branch !== '') {
|
|
2248
|
+
state.gitBranch = branch;
|
|
2249
|
+
state.emit();
|
|
2250
|
+
}
|
|
2251
|
+
})
|
|
2252
|
+
.catch(() => {
|
|
2253
|
+
// Git branch detection is best-effort; on Windows the sandbox
|
|
2254
|
+
// backend may be unavailable (no confinement yet) or the cwd may
|
|
2255
|
+
// not be a git repo. Either way the statusline simply stays blank.
|
|
2256
|
+
});
|
|
2257
|
+
}
|
|
2258
|
+
return state;
|
|
2259
|
+
}
|
|
2260
|
+
/** Path basename for the resume-list title (`C:/a/b` → `b`). */
|
|
2261
|
+
function basename(path) {
|
|
2262
|
+
const parts = path.split(/[\\/]/);
|
|
2263
|
+
return parts[parts.length - 1] ?? path;
|
|
2264
|
+
}
|
|
2265
|
+
/** Context-bar token estimate (pi-nano-context: ~4 chars per token). */
|
|
2266
|
+
function estimateTokens(text) {
|
|
2267
|
+
return Math.ceil(text.length / 4);
|
|
2268
|
+
}
|
|
2269
|
+
/** Character payload of one token-bearing stream delta for the live fallback. */
|
|
2270
|
+
function tokenDeltaChars(chunk) {
|
|
2271
|
+
switch (chunk.type) {
|
|
2272
|
+
case 'text-delta':
|
|
2273
|
+
case 'reasoning-delta':
|
|
2274
|
+
return chunk.text.length;
|
|
2275
|
+
case 'tool-call-delta':
|
|
2276
|
+
return (chunk.name?.length ?? 0) + chunk.argumentsDelta.length;
|
|
2277
|
+
default:
|
|
2278
|
+
return 0;
|
|
2279
|
+
}
|
|
2280
|
+
}
|
|
2281
|
+
/** Provider output count when usable; durable imports may predate strict validation. */
|
|
2282
|
+
function usageOutputTokens(usage) {
|
|
2283
|
+
if (typeof usage !== 'object' || usage === null)
|
|
2284
|
+
return undefined;
|
|
2285
|
+
const value = usage.outputTokens;
|
|
2286
|
+
return typeof value === 'number' && Number.isFinite(value) && value >= 0
|
|
2287
|
+
? value
|
|
2288
|
+
: undefined;
|
|
2289
|
+
}
|
|
2290
|
+
/**
|
|
2291
|
+
* Recursive `@` file listing through the leaf's fs service (dsh-fs-local):
|
|
2292
|
+
* walks up to MAX_DEPTH levels below `root`, skipping VCS/dependency dirs,
|
|
2293
|
+
* returning relative paths (directories with a trailing `/`, matching the
|
|
2294
|
+
* FileSuggestions tag logic) capped at MAX_FILES entries. Best-effort —
|
|
2295
|
+
* unreadable subtrees are skipped, not fatal.
|
|
2296
|
+
*/
|
|
2297
|
+
async function listFilesDeep(fs, root) {
|
|
2298
|
+
if (!fs)
|
|
2299
|
+
return [];
|
|
2300
|
+
const out = [];
|
|
2301
|
+
const SKIP = new Set(['node_modules', '.git', '.hg', '.svn', '.DS_Store', 'dist', 'build']);
|
|
2302
|
+
const MAX_DEPTH = 3;
|
|
2303
|
+
const MAX_FILES = 100;
|
|
2304
|
+
const walk = async (dir, prefix, depth) => {
|
|
2305
|
+
if (depth > MAX_DEPTH || out.length >= MAX_FILES)
|
|
2306
|
+
return;
|
|
2307
|
+
let entries = [];
|
|
2308
|
+
try {
|
|
2309
|
+
const target = await fs.resolve(dir);
|
|
2310
|
+
entries = await fs.listDir(target);
|
|
2311
|
+
}
|
|
2312
|
+
catch {
|
|
2313
|
+
return; // unreadable subtree — skip
|
|
2314
|
+
}
|
|
2315
|
+
for (const entry of entries) {
|
|
2316
|
+
if (out.length >= MAX_FILES)
|
|
2317
|
+
return;
|
|
2318
|
+
if (SKIP.has(entry.name))
|
|
2319
|
+
continue;
|
|
2320
|
+
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
2321
|
+
if (entry.type === 'directory') {
|
|
2322
|
+
out.push(`${rel}/`);
|
|
2323
|
+
// oxlint-disable-next-line typescript/no-unnecessary-condition -- runtime guard: symlink targets optional
|
|
2324
|
+
await walk(entry.target?.displayPath ?? join(dir, entry.name), rel, depth + 1);
|
|
2325
|
+
}
|
|
2326
|
+
else if (entry.type === 'file') {
|
|
2327
|
+
out.push(rel);
|
|
2328
|
+
}
|
|
2329
|
+
}
|
|
2330
|
+
};
|
|
2331
|
+
await walk(root, '', 1);
|
|
2332
|
+
return out;
|
|
2333
|
+
}
|
|
2334
|
+
/** One attached file's contribution is capped so an absent-minded `@` of a
|
|
2335
|
+
* huge file cannot blow the context window (CC caps @-attachments too). */
|
|
2336
|
+
const MENTION_MAX_FILE_CHARS = 50_000;
|
|
2337
|
+
/** Total budget across all attachments in one message. */
|
|
2338
|
+
const MENTION_MAX_TOTAL_CHARS = 200_000;
|
|
2339
|
+
/** A directory mention contributes a shallow listing, capped at this many
|
|
2340
|
+
* entries. */
|
|
2341
|
+
const MENTION_MAX_DIR_ENTRIES = 200;
|
|
2342
|
+
/** The leaf's fs service in the shape mention expansion needs; undefined
|
|
2343
|
+
* when the plugin is not mounted (mentions then stay literal text). */
|
|
2344
|
+
function mentionFs(ctx) {
|
|
2345
|
+
return ctx.get('fs');
|
|
2346
|
+
}
|
|
2347
|
+
/**
|
|
2348
|
+
* Expand a submitted text's `@` mentions (issue #15) into model-facing
|
|
2349
|
+
* attachment blocks: each referenced file contributes its (capped) content,
|
|
2350
|
+
* each directory a shallow listing. The typed text stays the first block
|
|
2351
|
+
* verbatim — mentions that resolve keep their `@path` spelling in it, and
|
|
2352
|
+
* unresolved ones stay literal everywhere. Best-effort: an unreadable or
|
|
2353
|
+
* binary file degrades to `missing`, never a failed send.
|
|
2354
|
+
*/
|
|
2355
|
+
export async function expandMentions(fs, cwd, text) {
|
|
2356
|
+
const blocks = [{ type: 'text', text }];
|
|
2357
|
+
const attached = [];
|
|
2358
|
+
const missing = [];
|
|
2359
|
+
const mentions = extractMentions(text);
|
|
2360
|
+
if (!fs || mentions.length === 0)
|
|
2361
|
+
return { blocks, attached, missing };
|
|
2362
|
+
let budget = MENTION_MAX_TOTAL_CHARS;
|
|
2363
|
+
for (const mention of mentions) {
|
|
2364
|
+
if (budget <= 0)
|
|
2365
|
+
break;
|
|
2366
|
+
// Mentions resolve against the session cwd, same as the model-facing fs
|
|
2367
|
+
// tools; absolute paths pass through untouched.
|
|
2368
|
+
const absolute = isAbsolute(mention.path) ? mention.path : join(cwd, mention.path);
|
|
2369
|
+
let target;
|
|
2370
|
+
let info;
|
|
2371
|
+
try {
|
|
2372
|
+
target = await fs.resolve(absolute);
|
|
2373
|
+
info = await fs.stat(target);
|
|
2374
|
+
}
|
|
2375
|
+
catch {
|
|
2376
|
+
missing.push(mention.path);
|
|
2377
|
+
continue;
|
|
2378
|
+
}
|
|
2379
|
+
if (info?.type === 'file') {
|
|
2380
|
+
try {
|
|
2381
|
+
const cap = Math.min(MENTION_MAX_FILE_CHARS, budget);
|
|
2382
|
+
let content = await fs.readText(target);
|
|
2383
|
+
let truncated = false;
|
|
2384
|
+
if (content.length > cap) {
|
|
2385
|
+
content = content.slice(0, cap);
|
|
2386
|
+
truncated = true;
|
|
2387
|
+
}
|
|
2388
|
+
budget -= content.length;
|
|
2389
|
+
blocks.push({
|
|
2390
|
+
type: 'text',
|
|
2391
|
+
text: `<attached-file path="${mention.path}">\n${content}${truncated ? '\n[… truncated]' : ''}\n</attached-file>`,
|
|
2392
|
+
});
|
|
2393
|
+
attached.push(mention.path);
|
|
2394
|
+
}
|
|
2395
|
+
catch {
|
|
2396
|
+
// Binary/undecodable or unreadable — report it like a miss.
|
|
2397
|
+
missing.push(mention.path);
|
|
2398
|
+
}
|
|
2399
|
+
continue;
|
|
2400
|
+
}
|
|
2401
|
+
if (info?.type === 'directory') {
|
|
2402
|
+
try {
|
|
2403
|
+
const entries = await fs.listDir(target);
|
|
2404
|
+
const listing = entries
|
|
2405
|
+
.slice(0, MENTION_MAX_DIR_ENTRIES)
|
|
2406
|
+
.map(entry => (entry.type === 'directory' ? `${entry.name}/` : entry.name));
|
|
2407
|
+
if (entries.length > MENTION_MAX_DIR_ENTRIES) {
|
|
2408
|
+
listing.push(`… (${entries.length - MENTION_MAX_DIR_ENTRIES} more)`);
|
|
2409
|
+
}
|
|
2410
|
+
const body = listing.join('\n');
|
|
2411
|
+
budget -= body.length;
|
|
2412
|
+
blocks.push({
|
|
2413
|
+
type: 'text',
|
|
2414
|
+
text: `<attached-directory path="${mention.path}">\n${body}\n</attached-directory>`,
|
|
2415
|
+
});
|
|
2416
|
+
attached.push(mention.path);
|
|
2417
|
+
}
|
|
2418
|
+
catch {
|
|
2419
|
+
missing.push(mention.path);
|
|
2420
|
+
}
|
|
2421
|
+
continue;
|
|
2422
|
+
}
|
|
2423
|
+
// Absent (stat → undefined) or a special file.
|
|
2424
|
+
missing.push(mention.path);
|
|
2425
|
+
}
|
|
2426
|
+
return { blocks, attached, missing };
|
|
2427
|
+
}
|