@neurocode-ai/tui 1.18.8
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/bunfig.toml +4 -0
- package/package.json +72 -0
- package/src/app.tsx +1134 -0
- package/src/attention.ts +260 -0
- package/src/audio.d.ts +9 -0
- package/src/audio.ts +53 -0
- package/src/clipboard.ts +124 -0
- package/src/component/bg-pulse-render.ts +436 -0
- package/src/component/bg-pulse.tsx +99 -0
- package/src/component/command-palette.tsx +79 -0
- package/src/component/dialog-agent.tsx +31 -0
- package/src/component/dialog-console-org.tsx +135 -0
- package/src/component/dialog-debug.tsx +90 -0
- package/src/component/dialog-mcp.tsx +85 -0
- package/src/component/dialog-model.tsx +197 -0
- package/src/component/dialog-move-session.tsx +353 -0
- package/src/component/dialog-provider.tsx +469 -0
- package/src/component/dialog-retry-action.tsx +160 -0
- package/src/component/dialog-session-delete-failed.tsx +99 -0
- package/src/component/dialog-session-list.tsx +364 -0
- package/src/component/dialog-session-rename.tsx +31 -0
- package/src/component/dialog-skill.tsx +70 -0
- package/src/component/dialog-stash.tsx +87 -0
- package/src/component/dialog-status.tsx +168 -0
- package/src/component/dialog-tag.tsx +47 -0
- package/src/component/dialog-theme-list.tsx +50 -0
- package/src/component/dialog-variant.tsx +39 -0
- package/src/component/dialog-workspace-create.tsx +308 -0
- package/src/component/dialog-workspace-file-changes.tsx +144 -0
- package/src/component/dialog-workspace-list.tsx +112 -0
- package/src/component/dialog-workspace-unavailable.tsx +69 -0
- package/src/component/error-component.tsx +240 -0
- package/src/component/logo.tsx +61 -0
- package/src/component/plugin-route-missing.tsx +14 -0
- package/src/component/prompt/autocomplete.tsx +781 -0
- package/src/component/prompt/cwd.ts +0 -0
- package/src/component/prompt/frecency.tsx +1 -0
- package/src/component/prompt/history.tsx +1 -0
- package/src/component/prompt/index.tsx +1713 -0
- package/src/component/prompt/local-attachment.ts +48 -0
- package/src/component/prompt/move.tsx +205 -0
- package/src/component/prompt/stash.tsx +1 -0
- package/src/component/prompt/workspace.tsx +137 -0
- package/src/component/register-spinner.ts +6 -0
- package/src/component/spinner.tsx +26 -0
- package/src/component/startup-loading.tsx +63 -0
- package/src/component/todo-item.tsx +32 -0
- package/src/component/use-connected.tsx +12 -0
- package/src/component/workspace-label.tsx +19 -0
- package/src/config/index.tsx +129 -0
- package/src/config/keybind.ts +471 -0
- package/src/context/args.tsx +16 -0
- package/src/context/clipboard.tsx +18 -0
- package/src/context/data.tsx +569 -0
- package/src/context/directory.ts +17 -0
- package/src/context/editor.ts +408 -0
- package/src/context/epilogue.tsx +6 -0
- package/src/context/event.ts +36 -0
- package/src/context/exit.tsx +8 -0
- package/src/context/helper.tsx +26 -0
- package/src/context/kv.tsx +66 -0
- package/src/context/local.tsx +542 -0
- package/src/context/location.tsx +14 -0
- package/src/context/path-format.tsx +24 -0
- package/src/context/permission.tsx +26 -0
- package/src/context/project.tsx +115 -0
- package/src/context/prompt.tsx +18 -0
- package/src/context/route.tsx +60 -0
- package/src/context/runtime.tsx +62 -0
- package/src/context/sdk.tsx +151 -0
- package/src/context/sync.tsx +666 -0
- package/src/context/theme.tsx +332 -0
- package/src/context/thinking.ts +67 -0
- package/src/editor-zed.ts +286 -0
- package/src/editor.ts +101 -0
- package/src/feature-plugins/builtins.ts +36 -0
- package/src/feature-plugins/home/footer.tsx +100 -0
- package/src/feature-plugins/home/tips-view.tsx +287 -0
- package/src/feature-plugins/home/tips.tsx +59 -0
- package/src/feature-plugins/sidebar/context.tsx +65 -0
- package/src/feature-plugins/sidebar/files.tsx +70 -0
- package/src/feature-plugins/sidebar/footer.tsx +98 -0
- package/src/feature-plugins/sidebar/lsp.tsx +65 -0
- package/src/feature-plugins/sidebar/mcp.tsx +97 -0
- package/src/feature-plugins/sidebar/todo.tsx +49 -0
- package/src/feature-plugins/system/diff-viewer-file-tree-utils.ts +232 -0
- package/src/feature-plugins/system/diff-viewer-file-tree.tsx +162 -0
- package/src/feature-plugins/system/diff-viewer-ui.tsx +103 -0
- package/src/feature-plugins/system/diff-viewer.tsx +1077 -0
- package/src/feature-plugins/system/notifications.ts +94 -0
- package/src/feature-plugins/system/plugins.tsx +269 -0
- package/src/feature-plugins/system/which-key.tsx +608 -0
- package/src/index.tsx +1 -0
- package/src/keymap.tsx +290 -0
- package/src/logo.ts +11 -0
- package/src/parsers-config.ts +386 -0
- package/src/plugin/adapters.tsx +355 -0
- package/src/plugin/api.ts +52 -0
- package/src/plugin/command-shim.ts +109 -0
- package/src/plugin/runtime.tsx +81 -0
- package/src/plugin/slots.tsx +65 -0
- package/src/prompt/display.ts +48 -0
- package/src/prompt/frecency.tsx +80 -0
- package/src/prompt/history.tsx +111 -0
- package/src/prompt/part.ts +29 -0
- package/src/prompt/stash.tsx +89 -0
- package/src/prompt/traits.ts +29 -0
- package/src/routes/home/session-destination.tsx +41 -0
- package/src/routes/home.tsx +95 -0
- package/src/routes/session/dialog-fork-from-timeline.tsx +76 -0
- package/src/routes/session/dialog-message.tsx +109 -0
- package/src/routes/session/dialog-subagent.tsx +26 -0
- package/src/routes/session/dialog-timeline.tsx +47 -0
- package/src/routes/session/footer.tsx +91 -0
- package/src/routes/session/index.tsx +2710 -0
- package/src/routes/session/permission.tsx +718 -0
- package/src/routes/session/question.tsx +514 -0
- package/src/routes/session/sidebar.tsx +103 -0
- package/src/routes/session/subagent-footer.tsx +132 -0
- package/src/runtime.tsx +9 -0
- package/src/terminal-win32.ts +130 -0
- package/src/theme/assets/aura.json +69 -0
- package/src/theme/assets/ayu.json +80 -0
- package/src/theme/assets/carbonfox.json +248 -0
- package/src/theme/assets/catppuccin-frappe.json +230 -0
- package/src/theme/assets/catppuccin-macchiato.json +230 -0
- package/src/theme/assets/catppuccin.json +112 -0
- package/src/theme/assets/cobalt2.json +225 -0
- package/src/theme/assets/cursor.json +249 -0
- package/src/theme/assets/dracula.json +219 -0
- package/src/theme/assets/everforest.json +241 -0
- package/src/theme/assets/flexoki.json +237 -0
- package/src/theme/assets/github.json +233 -0
- package/src/theme/assets/gruvbox.json +242 -0
- package/src/theme/assets/kanagawa.json +77 -0
- package/src/theme/assets/lucent-orng.json +234 -0
- package/src/theme/assets/material.json +235 -0
- package/src/theme/assets/matrix.json +77 -0
- package/src/theme/assets/mercury.json +252 -0
- package/src/theme/assets/monokai.json +221 -0
- package/src/theme/assets/nightowl.json +221 -0
- package/src/theme/assets/nord.json +223 -0
- package/src/theme/assets/one-dark.json +84 -0
- package/src/theme/assets/opencode.json +245 -0
- package/src/theme/assets/orng.json +249 -0
- package/src/theme/assets/osaka-jade.json +93 -0
- package/src/theme/assets/palenight.json +222 -0
- package/src/theme/assets/rosepine.json +234 -0
- package/src/theme/assets/solarized.json +223 -0
- package/src/theme/assets/synthwave84.json +226 -0
- package/src/theme/assets/tokyonight.json +243 -0
- package/src/theme/assets/vercel.json +245 -0
- package/src/theme/assets/vesper.json +218 -0
- package/src/theme/assets/zenburn.json +223 -0
- package/src/theme/index.ts +1089 -0
- package/src/ui/border.ts +21 -0
- package/src/ui/dialog-alert.tsx +66 -0
- package/src/ui/dialog-confirm.tsx +108 -0
- package/src/ui/dialog-export-options.tsx +217 -0
- package/src/ui/dialog-help.tsx +40 -0
- package/src/ui/dialog-prompt.tsx +126 -0
- package/src/ui/dialog-select.tsx +790 -0
- package/src/ui/dialog.tsx +231 -0
- package/src/ui/link.tsx +34 -0
- package/src/ui/spinner.ts +368 -0
- package/src/ui/toast.tsx +102 -0
- package/src/util/collapse-tool-output.ts +19 -0
- package/src/util/error.ts +182 -0
- package/src/util/filetype.ts +130 -0
- package/src/util/format.ts +20 -0
- package/src/util/layout.ts +25 -0
- package/src/util/locale.ts +86 -0
- package/src/util/model.ts +28 -0
- package/src/util/path.ts +12 -0
- package/src/util/persistence.ts +33 -0
- package/src/util/presentation.ts +38 -0
- package/src/util/provider-origin.ts +7 -0
- package/src/util/record.ts +3 -0
- package/src/util/renderer.ts +7 -0
- package/src/util/revert-diff.ts +18 -0
- package/src/util/scroll.ts +27 -0
- package/src/util/selection.ts +79 -0
- package/src/util/session.ts +3 -0
- package/src/util/signal.ts +51 -0
- package/src/util/system.ts +20 -0
- package/src/util/tool-display.ts +13 -0
- package/src/util/transcript.ts +112 -0
- package/sst-env.d.ts +10 -0
- package/test/app-lifecycle.test.tsx +128 -0
- package/test/cli/cmd/tui/dialog-workspace-create.test.ts +28 -0
- package/test/cli/cmd/tui/model-options.test.ts +32 -0
- package/test/cli/cmd/tui/notifications.test.ts +267 -0
- package/test/cli/cmd/tui/provider-options.test.ts +41 -0
- package/test/cli/cmd/tui/sync-fixture.tsx +70 -0
- package/test/cli/cmd/tui/sync-live-hydration.test.tsx +262 -0
- package/test/cli/cmd/tui/sync-undefined-messages.test.tsx +43 -0
- package/test/cli/cmd/tui/sync.test.tsx +65 -0
- package/test/cli/tui/__snapshots__/inline-tool-wrap-snapshot.test.tsx.snap +92 -0
- package/test/cli/tui/data.test.tsx +486 -0
- package/test/cli/tui/dialog-prompt.test.tsx +147 -0
- package/test/cli/tui/diff-viewer-file-tree.test.tsx +200 -0
- package/test/cli/tui/diff-viewer.test.tsx +268 -0
- package/test/cli/tui/inline-tool-wrap-snapshot.test.tsx +351 -0
- package/test/cli/tui/prompt-submit-race.test.ts +98 -0
- package/test/cli/tui/thinking.test.ts +36 -0
- package/test/cli/tui/use-event.test.tsx +148 -0
- package/test/clipboard.test.ts +19 -0
- package/test/component/dialog-session-list.test.ts +46 -0
- package/test/config.test.tsx +127 -0
- package/test/context/local.test.ts +22 -0
- package/test/editor.test.ts +32 -0
- package/test/feature-plugins/diff-viewer-file-tree-utils.test.ts +323 -0
- package/test/fixture/fixture.ts +13 -0
- package/test/fixture/tui-environment.tsx +32 -0
- package/test/fixture/tui-plugin.ts +36 -0
- package/test/fixture/tui-runtime.ts +12 -0
- package/test/fixture/tui-sdk.ts +109 -0
- package/test/index.test.tsx +6 -0
- package/test/keymap.test.tsx +141 -0
- package/test/plugin/runtime.test.ts +50 -0
- package/test/plugin/slots.test.tsx +38 -0
- package/test/prompt/display.test.ts +33 -0
- package/test/prompt/history.test.ts +39 -0
- package/test/prompt/jsonl.test.ts +24 -0
- package/test/prompt/local-attachment.test.ts +43 -0
- package/test/prompt/part.test.ts +53 -0
- package/test/prompt/persistence.test.ts +23 -0
- package/test/prompt/traits.test.ts +25 -0
- package/test/runtime.test.tsx +37 -0
- package/test/theme.test.ts +81 -0
- package/test/util/error.test.ts +49 -0
- package/test/util/filetype.test.ts +16 -0
- package/test/util/format.test.ts +59 -0
- package/test/util/model.test.ts +9 -0
- package/test/util/presentation.test.ts +8 -0
- package/test/util/renderer.test.ts +30 -0
- package/test/util/revert-diff.test.ts +35 -0
- package/test/util/session.test.ts +10 -0
- package/test/util/tool-display.test.ts +40 -0
- package/test/util/transcript.test.ts +421 -0
- package/tsconfig.json +10 -0
|
@@ -0,0 +1,2710 @@
|
|
|
1
|
+
import {
|
|
2
|
+
batch,
|
|
3
|
+
createContext,
|
|
4
|
+
createEffect,
|
|
5
|
+
createMemo,
|
|
6
|
+
createSignal,
|
|
7
|
+
For,
|
|
8
|
+
Match,
|
|
9
|
+
on,
|
|
10
|
+
onCleanup,
|
|
11
|
+
onMount,
|
|
12
|
+
Show,
|
|
13
|
+
Switch,
|
|
14
|
+
untrack,
|
|
15
|
+
useContext,
|
|
16
|
+
} from "solid-js"
|
|
17
|
+
import { Dynamic } from "solid-js/web"
|
|
18
|
+
import path from "node:path"
|
|
19
|
+
import { mkdir, writeFile } from "node:fs/promises"
|
|
20
|
+
import { useRoute, useRouteData } from "../../context/route"
|
|
21
|
+
import { useProject } from "../../context/project"
|
|
22
|
+
import { useSync } from "../../context/sync"
|
|
23
|
+
import { useEvent } from "../../context/event"
|
|
24
|
+
import { SplitBorder } from "../../ui/border"
|
|
25
|
+
import { useTuiPaths, useTuiTerminalEnvironment } from "../../context/runtime"
|
|
26
|
+
import { Spinner } from "../../component/spinner"
|
|
27
|
+
import { createSyntaxStyleMemo, generateSubtleSyntax, selectedForeground, useTheme } from "../../context/theme"
|
|
28
|
+
import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA } from "@opentui/core"
|
|
29
|
+
import { Prompt, type PromptRef } from "../../component/prompt"
|
|
30
|
+
import type {
|
|
31
|
+
AssistantMessage,
|
|
32
|
+
Part,
|
|
33
|
+
Provider,
|
|
34
|
+
ToolPart,
|
|
35
|
+
UserMessage,
|
|
36
|
+
TextPart,
|
|
37
|
+
ReasoningPart,
|
|
38
|
+
SessionStatus,
|
|
39
|
+
} from "@neurocode-ai/sdk/v2"
|
|
40
|
+
import { useLocal } from "../../context/local"
|
|
41
|
+
import { Locale } from "../../util/locale"
|
|
42
|
+
import { webSearchProviderLabel } from "../../util/tool-display"
|
|
43
|
+
import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
|
|
44
|
+
import { useSDK } from "../../context/sdk"
|
|
45
|
+
import { useEditorContext } from "../../context/editor"
|
|
46
|
+
import { openEditor } from "../../editor"
|
|
47
|
+
import { useDialog } from "../../ui/dialog"
|
|
48
|
+
import { DialogAlert } from "../../ui/dialog-alert"
|
|
49
|
+
import { TodoItem } from "../../component/todo-item"
|
|
50
|
+
import { DialogMessage } from "./dialog-message"
|
|
51
|
+
import type { PromptInfo } from "../../component/prompt/history"
|
|
52
|
+
import { DialogConfirm } from "../../ui/dialog-confirm"
|
|
53
|
+
import { DialogTimeline } from "./dialog-timeline"
|
|
54
|
+
import { DialogForkFromTimeline } from "./dialog-fork-from-timeline"
|
|
55
|
+
import { DialogSessionRename } from "../../component/dialog-session-rename"
|
|
56
|
+
import { Sidebar } from "./sidebar"
|
|
57
|
+
import { SubagentFooter } from "./subagent-footer.tsx"
|
|
58
|
+
import { filetype } from "../../util/filetype"
|
|
59
|
+
import parsers from "../../parsers-config"
|
|
60
|
+
import { errorMessage } from "../../util/error"
|
|
61
|
+
import { Toast, useToast } from "../../ui/toast"
|
|
62
|
+
import { useKV } from "../../context/kv.tsx"
|
|
63
|
+
import stripAnsi from "strip-ansi"
|
|
64
|
+
import { usePromptRef } from "../../context/prompt"
|
|
65
|
+
import { useEpilogue } from "../../context/epilogue"
|
|
66
|
+
import { normalizePath } from "../../util/path"
|
|
67
|
+
import { PermissionPrompt } from "./permission"
|
|
68
|
+
import { QuestionPrompt } from "./question"
|
|
69
|
+
import { DialogExportOptions } from "../../ui/dialog-export-options"
|
|
70
|
+
import * as Model from "../../util/model"
|
|
71
|
+
import { formatTranscript } from "../../util/transcript"
|
|
72
|
+
import { sessionEpilogue } from "../../util/presentation"
|
|
73
|
+
import { setPreLayoutSiblingMargin } from "../../util/layout"
|
|
74
|
+
import { useTuiConfig } from "../../config"
|
|
75
|
+
import { useClipboard } from "../../context/clipboard"
|
|
76
|
+
import { nextThinkingMode, reasoningSummary, useThinkingMode, type ThinkingMode } from "../../context/thinking"
|
|
77
|
+
import { getScrollAcceleration } from "../../util/scroll"
|
|
78
|
+
import { collapseToolOutput } from "../../util/collapse-tool-output"
|
|
79
|
+
import { usePluginRuntime } from "../../plugin/runtime"
|
|
80
|
+
import { DialogRetryAction } from "../../component/dialog-retry-action"
|
|
81
|
+
import { getRevertDiffFiles } from "../../util/revert-diff"
|
|
82
|
+
import { NEUROCODE_BASE_MODE, useBindings, useCommandShortcut, useNeurocodeKeymap } from "../../keymap"
|
|
83
|
+
import { usePathFormatter } from "../../context/path-format"
|
|
84
|
+
import { LocationProvider } from "../../context/location"
|
|
85
|
+
|
|
86
|
+
addDefaultParsers(parsers.parsers)
|
|
87
|
+
|
|
88
|
+
const GO_UPSELL_FREE_TIER_LAST_SEEN_AT = "go_upsell_last_seen_at"
|
|
89
|
+
const GO_UPSELL_FREE_TIER_DONT_SHOW = "go_upsell_dont_show"
|
|
90
|
+
const GO_UPSELL_ACCOUNT_RATE_LIMIT_LAST_SEEN_AT = "go_upsell_account_rate_limit_last_seen_at"
|
|
91
|
+
const GO_UPSELL_ACCOUNT_RATE_LIMIT_DONT_SHOW = "go_upsell_account_rate_limit_dont_show"
|
|
92
|
+
const GO_UPSELL_WINDOW = 86_400_000 // 24 hrs
|
|
93
|
+
const GO_UPSELL_PROVIDERS = new Set(["opencode", "opencode-go"])
|
|
94
|
+
|
|
95
|
+
export const alwaysSeparate = new WeakSet<BoxRenderable>()
|
|
96
|
+
|
|
97
|
+
type RetryAction = Extract<SessionStatus, { type: "retry" }>["action"]
|
|
98
|
+
|
|
99
|
+
function goUpsellKeys(action: RetryAction) {
|
|
100
|
+
if (!action) return
|
|
101
|
+
if (!GO_UPSELL_PROVIDERS.has(action.provider)) return
|
|
102
|
+
if (action.reason === "free_tier_limit") {
|
|
103
|
+
return {
|
|
104
|
+
lastSeenAt: GO_UPSELL_FREE_TIER_LAST_SEEN_AT,
|
|
105
|
+
dontShow: GO_UPSELL_FREE_TIER_DONT_SHOW,
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (action.reason === "account_rate_limit") {
|
|
109
|
+
return {
|
|
110
|
+
lastSeenAt: GO_UPSELL_ACCOUNT_RATE_LIMIT_LAST_SEEN_AT,
|
|
111
|
+
dontShow: GO_UPSELL_ACCOUNT_RATE_LIMIT_DONT_SHOW,
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const sessionBindingCommands = [
|
|
117
|
+
"session.share",
|
|
118
|
+
"session.rename",
|
|
119
|
+
"session.timeline",
|
|
120
|
+
"session.fork",
|
|
121
|
+
"session.compact",
|
|
122
|
+
"session.unshare",
|
|
123
|
+
"session.undo",
|
|
124
|
+
"session.redo",
|
|
125
|
+
"session.sidebar.toggle",
|
|
126
|
+
"session.toggle.conceal",
|
|
127
|
+
"session.toggle.timestamps",
|
|
128
|
+
"session.toggle.thinking",
|
|
129
|
+
"session.toggle.actions",
|
|
130
|
+
"session.toggle.scrollbar",
|
|
131
|
+
"session.toggle.generic_tool_output",
|
|
132
|
+
"session.first",
|
|
133
|
+
"session.last",
|
|
134
|
+
"session.messages_last_user",
|
|
135
|
+
"session.message.next",
|
|
136
|
+
"session.message.previous",
|
|
137
|
+
"messages.copy",
|
|
138
|
+
"session.copy",
|
|
139
|
+
"session.export",
|
|
140
|
+
"session.child.first",
|
|
141
|
+
"session.parent",
|
|
142
|
+
"session.child.next",
|
|
143
|
+
"session.child.previous",
|
|
144
|
+
] as const
|
|
145
|
+
|
|
146
|
+
const sessionGlobalBindingCommands = [
|
|
147
|
+
"session.page.up",
|
|
148
|
+
"session.page.down",
|
|
149
|
+
"session.line.up",
|
|
150
|
+
"session.line.down",
|
|
151
|
+
"session.half.page.up",
|
|
152
|
+
"session.half.page.down",
|
|
153
|
+
] as const
|
|
154
|
+
|
|
155
|
+
const sessionGlobalUnfocusedBindingCommands = ["session.first", "session.last"] as const
|
|
156
|
+
|
|
157
|
+
const context = createContext<{
|
|
158
|
+
width: number
|
|
159
|
+
sessionID: string
|
|
160
|
+
conceal: () => boolean
|
|
161
|
+
thinkingMode: () => ThinkingMode
|
|
162
|
+
showThinking: () => boolean
|
|
163
|
+
showTimestamps: () => boolean
|
|
164
|
+
showDetails: () => boolean
|
|
165
|
+
showGenericToolOutput: () => boolean
|
|
166
|
+
diffWrapMode: () => "word" | "none"
|
|
167
|
+
providers: () => ReadonlyMap<string, Provider>
|
|
168
|
+
sync: ReturnType<typeof useSync>
|
|
169
|
+
tui: ReturnType<typeof useTuiConfig>
|
|
170
|
+
}>()
|
|
171
|
+
|
|
172
|
+
function use() {
|
|
173
|
+
const ctx = useContext(context)
|
|
174
|
+
if (!ctx) throw new Error("useContext must be used within a Session component")
|
|
175
|
+
return ctx
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function Session() {
|
|
179
|
+
const setEpilogue = useEpilogue()
|
|
180
|
+
const clipboard = useClipboard()
|
|
181
|
+
const writeExport = async (file: string, content: string) => {
|
|
182
|
+
await mkdir(path.dirname(file), { recursive: true })
|
|
183
|
+
await writeFile(file, content)
|
|
184
|
+
}
|
|
185
|
+
const pluginRuntime = usePluginRuntime()
|
|
186
|
+
const route = useRouteData("session")
|
|
187
|
+
const { navigate } = useRoute()
|
|
188
|
+
const sync = useSync()
|
|
189
|
+
const event = useEvent()
|
|
190
|
+
const project = useProject()
|
|
191
|
+
const paths = useTuiPaths()
|
|
192
|
+
const tuiConfig = useTuiConfig()
|
|
193
|
+
const kv = useKV()
|
|
194
|
+
const { theme } = useTheme()
|
|
195
|
+
const promptRef = usePromptRef()
|
|
196
|
+
const session = createMemo(() => sync.session.get(route.sessionID))
|
|
197
|
+
const location = createMemo(() => {
|
|
198
|
+
const current = session()
|
|
199
|
+
return current ? { directory: current.directory, workspaceID: current.workspaceID } : undefined
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
createEffect(() => {
|
|
203
|
+
const title = Locale.truncate(session()?.title ?? "", 50)
|
|
204
|
+
setEpilogue(sessionEpilogue({ title, sessionID: session()?.id }))
|
|
205
|
+
})
|
|
206
|
+
onCleanup(() => setEpilogue())
|
|
207
|
+
const children = createMemo(() => {
|
|
208
|
+
const parentID = session()?.parentID ?? session()?.id
|
|
209
|
+
return sync.data.session
|
|
210
|
+
.filter((x) => x.parentID === parentID || x.id === parentID)
|
|
211
|
+
.toSorted((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
|
|
212
|
+
})
|
|
213
|
+
const messages = createMemo(() => sync.data.message[route.sessionID] ?? [])
|
|
214
|
+
const foregroundTasks = createMemo(() =>
|
|
215
|
+
sync.data.capabilities.experimentalBackgroundSubagents
|
|
216
|
+
? messages().flatMap((message) =>
|
|
217
|
+
(sync.data.part[message.id] ?? []).filter(
|
|
218
|
+
(part): part is ToolPart =>
|
|
219
|
+
part.type === "tool" &&
|
|
220
|
+
part.tool === "task" &&
|
|
221
|
+
part.state.status === "running" &&
|
|
222
|
+
part.state.metadata?.background !== true,
|
|
223
|
+
),
|
|
224
|
+
)
|
|
225
|
+
: [],
|
|
226
|
+
)
|
|
227
|
+
const permissions = createMemo(() => {
|
|
228
|
+
if (session()?.parentID) return []
|
|
229
|
+
return children().flatMap((x) => sync.data.permission[x.id] ?? [])
|
|
230
|
+
})
|
|
231
|
+
const questions = createMemo(() => {
|
|
232
|
+
if (session()?.parentID) return []
|
|
233
|
+
return children().flatMap((x) => sync.data.question[x.id] ?? [])
|
|
234
|
+
})
|
|
235
|
+
const visible = createMemo(() => !session()?.parentID && permissions().length === 0 && questions().length === 0)
|
|
236
|
+
const disabled = createMemo(() => permissions().length > 0 || questions().length > 0)
|
|
237
|
+
|
|
238
|
+
const pending = createMemo(() => {
|
|
239
|
+
const completed = messages().findLast((x) => x.role === "assistant" && x.time.completed)?.id
|
|
240
|
+
return messages().findLast((x) => x.role === "assistant" && !x.time.completed && (!completed || x.id > completed))
|
|
241
|
+
?.id
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
const lastAssistant = createMemo(() => {
|
|
245
|
+
return messages().findLast((x) => x.role === "assistant")
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
const dimensions = useTerminalDimensions()
|
|
249
|
+
const [sidebar, setSidebar] = kv.signal<"auto" | "hide">("sidebar", "auto")
|
|
250
|
+
const [sidebarOpen, setSidebarOpen] = createSignal(false)
|
|
251
|
+
const [conceal, setConceal] = createSignal(true)
|
|
252
|
+
const thinking = useThinkingMode()
|
|
253
|
+
const thinkingMode = thinking.mode
|
|
254
|
+
const showThinking = createMemo(() => true)
|
|
255
|
+
const [timestamps, setTimestamps] = kv.signal<"hide" | "show">("timestamps", "hide")
|
|
256
|
+
const [showDetails, setShowDetails] = kv.signal("tool_details_visibility", true)
|
|
257
|
+
const [showAssistantMetadata, _setShowAssistantMetadata] = kv.signal("assistant_metadata_visibility", true)
|
|
258
|
+
const [showScrollbar, setShowScrollbar] = kv.signal("scrollbar_visible", false)
|
|
259
|
+
const [diffWrapMode] = kv.signal<"word" | "none">("diff_wrap_mode", "word")
|
|
260
|
+
const [_animationsEnabled, _setAnimationsEnabled] = kv.signal("animations_enabled", true)
|
|
261
|
+
const [showGenericToolOutput, setShowGenericToolOutput] = kv.signal("generic_tool_output_visibility", false)
|
|
262
|
+
|
|
263
|
+
const wide = createMemo(() => dimensions().width > 120)
|
|
264
|
+
const sidebarVisible = createMemo(() => {
|
|
265
|
+
if (session()?.parentID) return false
|
|
266
|
+
if (sidebarOpen()) return true
|
|
267
|
+
if (sidebar() === "auto" && wide()) return true
|
|
268
|
+
return false
|
|
269
|
+
})
|
|
270
|
+
const showTimestamps = createMemo(() => timestamps() === "show")
|
|
271
|
+
const contentWidth = createMemo(() => dimensions().width - (sidebarVisible() ? 42 : 0) - 4)
|
|
272
|
+
const providers = createMemo(() => Model.index(sync.data.provider))
|
|
273
|
+
|
|
274
|
+
const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig))
|
|
275
|
+
const toast = useToast()
|
|
276
|
+
const sdk = useSDK()
|
|
277
|
+
const editor = useEditorContext()
|
|
278
|
+
|
|
279
|
+
createEffect(() => {
|
|
280
|
+
const sessionID = route.sessionID
|
|
281
|
+
void (async () => {
|
|
282
|
+
const previousWorkspace = untrack(() => project.workspace.current())
|
|
283
|
+
const result = await sdk.client.session.get({ sessionID }, { throwOnError: true })
|
|
284
|
+
if (!result.data) {
|
|
285
|
+
toast.show({
|
|
286
|
+
message: `Session not found: ${sessionID}`,
|
|
287
|
+
variant: "error",
|
|
288
|
+
duration: 5000,
|
|
289
|
+
})
|
|
290
|
+
navigate({ type: "home" })
|
|
291
|
+
return
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (result.data.workspaceID !== previousWorkspace) {
|
|
295
|
+
project.workspace.set(result.data.workspaceID)
|
|
296
|
+
|
|
297
|
+
// Sync all the data for this workspace. Note that this
|
|
298
|
+
// workspace may not exist anymore which is why this is not
|
|
299
|
+
// fatal. If it doesn't we still want to show the session
|
|
300
|
+
// (which will be non-interactive)
|
|
301
|
+
try {
|
|
302
|
+
await sync.bootstrap({ fatal: false })
|
|
303
|
+
} catch {}
|
|
304
|
+
}
|
|
305
|
+
editor.reconnect(result.data.directory)
|
|
306
|
+
await sync.session.sync(sessionID)
|
|
307
|
+
if (route.sessionID === sessionID && scroll) scroll.scrollBy(100_000)
|
|
308
|
+
})().catch((error) => {
|
|
309
|
+
if (route.sessionID !== sessionID) return
|
|
310
|
+
toast.show({
|
|
311
|
+
message: errorMessage(error),
|
|
312
|
+
variant: "error",
|
|
313
|
+
duration: 5000,
|
|
314
|
+
})
|
|
315
|
+
navigate({ type: "home" })
|
|
316
|
+
})
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
let lastSwitch: string | undefined = undefined
|
|
320
|
+
event.on("message.part.updated", (evt) => {
|
|
321
|
+
const part = evt.properties.part
|
|
322
|
+
if (part.type !== "tool") return
|
|
323
|
+
if (part.sessionID !== route.sessionID) return
|
|
324
|
+
if (part.state.status !== "completed") return
|
|
325
|
+
if (part.id === lastSwitch) return
|
|
326
|
+
|
|
327
|
+
if (part.tool === "plan_exit") {
|
|
328
|
+
local.agent.set("build")
|
|
329
|
+
lastSwitch = part.id
|
|
330
|
+
} else if (part.tool === "plan_enter") {
|
|
331
|
+
local.agent.set("plan")
|
|
332
|
+
lastSwitch = part.id
|
|
333
|
+
}
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
let seeded = false
|
|
337
|
+
let scroll: ScrollBoxRenderable
|
|
338
|
+
let prompt: PromptRef | undefined
|
|
339
|
+
const bind = (r: PromptRef | undefined) => {
|
|
340
|
+
prompt = r
|
|
341
|
+
promptRef.set(r)
|
|
342
|
+
if (seeded || !route.prompt || !r) return
|
|
343
|
+
seeded = true
|
|
344
|
+
r.set(route.prompt)
|
|
345
|
+
}
|
|
346
|
+
const keymap = useNeurocodeKeymap()
|
|
347
|
+
const dialog = useDialog()
|
|
348
|
+
const renderer = useRenderer()
|
|
349
|
+
|
|
350
|
+
event.on("session.status", (evt) => {
|
|
351
|
+
if (evt.properties.sessionID !== route.sessionID) return
|
|
352
|
+
if (evt.properties.status.type !== "retry") return
|
|
353
|
+
if (!evt.properties.status.action) return
|
|
354
|
+
if (dialog.stack.length > 0) return
|
|
355
|
+
|
|
356
|
+
const keys = goUpsellKeys(evt.properties.status.action)
|
|
357
|
+
if (!keys) return
|
|
358
|
+
|
|
359
|
+
const seen = kv.get(keys.lastSeenAt)
|
|
360
|
+
if (typeof seen === "number" && Date.now() - seen < GO_UPSELL_WINDOW) return
|
|
361
|
+
|
|
362
|
+
if (kv.get(keys.dontShow)) return
|
|
363
|
+
|
|
364
|
+
void DialogRetryAction.show(dialog, evt.properties.status.action).then((dontShowAgain) => {
|
|
365
|
+
if (dontShowAgain) kv.set(keys.dontShow, true)
|
|
366
|
+
kv.set(keys.lastSeenAt, Date.now())
|
|
367
|
+
})
|
|
368
|
+
})
|
|
369
|
+
|
|
370
|
+
// Helper: Find next visible message boundary in direction
|
|
371
|
+
const findNextVisibleMessage = (direction: "next" | "prev"): string | null => {
|
|
372
|
+
const children = scroll.getChildren()
|
|
373
|
+
const messagesList = messages()
|
|
374
|
+
const scrollTop = scroll.y
|
|
375
|
+
|
|
376
|
+
// Get visible messages sorted by position, filtering for valid non-synthetic, non-ignored content
|
|
377
|
+
const visibleMessages = children
|
|
378
|
+
.filter((c) => {
|
|
379
|
+
if (!c.id) return false
|
|
380
|
+
const message = messagesList.find((m) => m.id === c.id)
|
|
381
|
+
if (!message) return false
|
|
382
|
+
|
|
383
|
+
// Check if message has valid non-synthetic, non-ignored text parts
|
|
384
|
+
const parts = sync.data.part[message.id]
|
|
385
|
+
if (!parts || !Array.isArray(parts)) return false
|
|
386
|
+
|
|
387
|
+
return parts.some((part) => part && part.type === "text" && !part.synthetic && !part.ignored)
|
|
388
|
+
})
|
|
389
|
+
.sort((a, b) => a.y - b.y)
|
|
390
|
+
|
|
391
|
+
if (visibleMessages.length === 0) return null
|
|
392
|
+
|
|
393
|
+
if (direction === "next") {
|
|
394
|
+
// Find first message below current position
|
|
395
|
+
return visibleMessages.find((c) => c.y > scrollTop + 10)?.id ?? null
|
|
396
|
+
}
|
|
397
|
+
// Find last message above current position
|
|
398
|
+
return [...visibleMessages].reverse().find((c) => c.y < scrollTop - 10)?.id ?? null
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Helper: Scroll to message in direction or fallback to page scroll
|
|
402
|
+
const scrollToMessage = (direction: "next" | "prev", dialog: ReturnType<typeof useDialog>) => {
|
|
403
|
+
const targetID = findNextVisibleMessage(direction)
|
|
404
|
+
|
|
405
|
+
if (!targetID) {
|
|
406
|
+
scroll.scrollBy(direction === "next" ? scroll.height : -scroll.height)
|
|
407
|
+
dialog.clear()
|
|
408
|
+
return
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const child = scroll.getChildren().find((c) => c.id === targetID)
|
|
412
|
+
if (child) scroll.scrollBy(child.y - scroll.y - 1)
|
|
413
|
+
dialog.clear()
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function toBottom() {
|
|
417
|
+
setTimeout(() => {
|
|
418
|
+
if (!scroll || scroll.isDestroyed) return
|
|
419
|
+
scroll.scrollTo(scroll.scrollHeight)
|
|
420
|
+
}, 50)
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const local = useLocal()
|
|
424
|
+
|
|
425
|
+
function enterChild(sessionID: string) {
|
|
426
|
+
navigate({
|
|
427
|
+
type: "session",
|
|
428
|
+
sessionID,
|
|
429
|
+
})
|
|
430
|
+
const status = sync.data.session_status[sessionID]
|
|
431
|
+
if (status?.type === "retry") void DialogAlert.show(dialog, "Retry Error", status.message)
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function moveFirstChild() {
|
|
435
|
+
if (children().length === 1) return
|
|
436
|
+
const next = children().find((x) => !!x.parentID)
|
|
437
|
+
if (next) enterChild(next.id)
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function moveChild(direction: number) {
|
|
441
|
+
if (children().length === 1) return
|
|
442
|
+
|
|
443
|
+
const sessions = children().filter((x) => !!x.parentID)
|
|
444
|
+
let next = sessions.findIndex((x) => x.id === session()?.id) - direction
|
|
445
|
+
|
|
446
|
+
if (next >= sessions.length) next = 0
|
|
447
|
+
if (next < 0) next = sessions.length - 1
|
|
448
|
+
if (sessions[next]) enterChild(sessions[next].id)
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function childSessionHandler(func: () => void) {
|
|
452
|
+
return () => {
|
|
453
|
+
if (!session()?.parentID || dialog.stack.length > 0) return
|
|
454
|
+
func()
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const sessionCommandList = createMemo(() => [
|
|
459
|
+
{
|
|
460
|
+
title: session()?.share?.url ? "Copy share link" : "Share session",
|
|
461
|
+
value: "session.share",
|
|
462
|
+
suggested: route.type === "session",
|
|
463
|
+
category: "Session",
|
|
464
|
+
enabled: sync.data.config.share !== "disabled",
|
|
465
|
+
slash: {
|
|
466
|
+
name: "share",
|
|
467
|
+
},
|
|
468
|
+
run: async () => {
|
|
469
|
+
const copy = (url: string) =>
|
|
470
|
+
clipboard
|
|
471
|
+
.write?.(url)
|
|
472
|
+
.then(() => toast.show({ message: "Share URL copied to clipboard!", variant: "success" }))
|
|
473
|
+
.catch(() => toast.show({ message: "Failed to copy URL to clipboard", variant: "error" }))
|
|
474
|
+
const url = session()?.share?.url
|
|
475
|
+
if (url) {
|
|
476
|
+
await copy(url)
|
|
477
|
+
dialog.clear()
|
|
478
|
+
return
|
|
479
|
+
}
|
|
480
|
+
if (!kv.get("share_consent", false)) {
|
|
481
|
+
const ok = await DialogConfirm.show(dialog, "Share Session", "Are you sure you want to share it?")
|
|
482
|
+
if (ok !== true) return
|
|
483
|
+
kv.set("share_consent", true)
|
|
484
|
+
}
|
|
485
|
+
await sdk.client.session
|
|
486
|
+
.share({
|
|
487
|
+
sessionID: route.sessionID,
|
|
488
|
+
})
|
|
489
|
+
.then((res) => copy(res.data!.share!.url))
|
|
490
|
+
.catch((error) => {
|
|
491
|
+
toast.show({
|
|
492
|
+
message: error instanceof Error ? error.message : "Failed to share session",
|
|
493
|
+
variant: "error",
|
|
494
|
+
})
|
|
495
|
+
})
|
|
496
|
+
dialog.clear()
|
|
497
|
+
},
|
|
498
|
+
},
|
|
499
|
+
{
|
|
500
|
+
title: "Rename session",
|
|
501
|
+
value: "session.rename",
|
|
502
|
+
category: "Session",
|
|
503
|
+
slash: {
|
|
504
|
+
name: "rename",
|
|
505
|
+
},
|
|
506
|
+
run: () => {
|
|
507
|
+
dialog.replace(() => <DialogSessionRename session={route.sessionID} />)
|
|
508
|
+
},
|
|
509
|
+
},
|
|
510
|
+
{
|
|
511
|
+
title: "Jump to message",
|
|
512
|
+
value: "session.timeline",
|
|
513
|
+
category: "Session",
|
|
514
|
+
slash: {
|
|
515
|
+
name: "timeline",
|
|
516
|
+
},
|
|
517
|
+
run: () => {
|
|
518
|
+
dialog.replace(() => (
|
|
519
|
+
<DialogTimeline
|
|
520
|
+
onMove={(messageID) => {
|
|
521
|
+
const child = scroll.getChildren().find((child) => {
|
|
522
|
+
return child.id === messageID
|
|
523
|
+
})
|
|
524
|
+
if (child) scroll.scrollBy(child.y - scroll.y - 1)
|
|
525
|
+
}}
|
|
526
|
+
sessionID={route.sessionID}
|
|
527
|
+
setPrompt={(promptInfo) => prompt?.set(promptInfo)}
|
|
528
|
+
/>
|
|
529
|
+
))
|
|
530
|
+
},
|
|
531
|
+
},
|
|
532
|
+
{
|
|
533
|
+
title: "Fork session",
|
|
534
|
+
value: "session.fork",
|
|
535
|
+
category: "Session",
|
|
536
|
+
slash: {
|
|
537
|
+
name: "fork",
|
|
538
|
+
},
|
|
539
|
+
run: () => {
|
|
540
|
+
dialog.replace(() => (
|
|
541
|
+
<DialogForkFromTimeline
|
|
542
|
+
onMove={(messageID) => {
|
|
543
|
+
if (!messageID) return
|
|
544
|
+
const child = scroll.getChildren().find((child) => {
|
|
545
|
+
return child.id === messageID
|
|
546
|
+
})
|
|
547
|
+
if (child) scroll.scrollBy(child.y - scroll.y - 1)
|
|
548
|
+
}}
|
|
549
|
+
sessionID={route.sessionID}
|
|
550
|
+
/>
|
|
551
|
+
))
|
|
552
|
+
},
|
|
553
|
+
},
|
|
554
|
+
{
|
|
555
|
+
title: "Compact session",
|
|
556
|
+
value: "session.compact",
|
|
557
|
+
category: "Session",
|
|
558
|
+
slash: {
|
|
559
|
+
name: "compact",
|
|
560
|
+
aliases: ["summarize"],
|
|
561
|
+
},
|
|
562
|
+
run: () => {
|
|
563
|
+
const selectedModel = local.model.current()
|
|
564
|
+
if (!selectedModel) {
|
|
565
|
+
toast.show({
|
|
566
|
+
variant: "warning",
|
|
567
|
+
message: "Connect a provider to summarize this session",
|
|
568
|
+
duration: 3000,
|
|
569
|
+
})
|
|
570
|
+
return
|
|
571
|
+
}
|
|
572
|
+
void sdk.client.session.summarize({
|
|
573
|
+
sessionID: route.sessionID,
|
|
574
|
+
modelID: selectedModel.modelID,
|
|
575
|
+
providerID: selectedModel.providerID,
|
|
576
|
+
})
|
|
577
|
+
dialog.clear()
|
|
578
|
+
},
|
|
579
|
+
},
|
|
580
|
+
{
|
|
581
|
+
title: "Unshare session",
|
|
582
|
+
value: "session.unshare",
|
|
583
|
+
category: "Session",
|
|
584
|
+
enabled: !!session()?.share?.url,
|
|
585
|
+
slash: {
|
|
586
|
+
name: "unshare",
|
|
587
|
+
},
|
|
588
|
+
run: async () => {
|
|
589
|
+
await sdk.client.session
|
|
590
|
+
.unshare({
|
|
591
|
+
sessionID: route.sessionID,
|
|
592
|
+
})
|
|
593
|
+
.then(() => toast.show({ message: "Session unshared successfully", variant: "success" }))
|
|
594
|
+
.catch((error) => {
|
|
595
|
+
toast.show({
|
|
596
|
+
message: error instanceof Error ? error.message : "Failed to unshare session",
|
|
597
|
+
variant: "error",
|
|
598
|
+
})
|
|
599
|
+
})
|
|
600
|
+
dialog.clear()
|
|
601
|
+
},
|
|
602
|
+
},
|
|
603
|
+
{
|
|
604
|
+
title: "Undo previous message",
|
|
605
|
+
value: "session.undo",
|
|
606
|
+
category: "Session",
|
|
607
|
+
slash: {
|
|
608
|
+
name: "undo",
|
|
609
|
+
},
|
|
610
|
+
run: async () => {
|
|
611
|
+
const status = sync.data.session_status?.[route.sessionID]
|
|
612
|
+
if (status?.type !== "idle") await sdk.client.session.abort({ sessionID: route.sessionID }).catch(() => {})
|
|
613
|
+
const revert = session()?.revert?.messageID
|
|
614
|
+
const message = messages().findLast((x) => (!revert || x.id < revert) && x.role === "user")
|
|
615
|
+
if (!message) return
|
|
616
|
+
void sdk.client.session
|
|
617
|
+
.revert({
|
|
618
|
+
sessionID: route.sessionID,
|
|
619
|
+
messageID: message.id,
|
|
620
|
+
})
|
|
621
|
+
.then(() => {
|
|
622
|
+
toBottom()
|
|
623
|
+
})
|
|
624
|
+
const parts = sync.data.part[message.id]
|
|
625
|
+
prompt?.set(
|
|
626
|
+
parts.reduce(
|
|
627
|
+
(agg, part) => {
|
|
628
|
+
if (part.type === "text") {
|
|
629
|
+
if (!part.synthetic) agg.input += part.text
|
|
630
|
+
}
|
|
631
|
+
if (part.type === "file") agg.parts.push(part)
|
|
632
|
+
return agg
|
|
633
|
+
},
|
|
634
|
+
{ input: "", parts: [] as PromptInfo["parts"] },
|
|
635
|
+
),
|
|
636
|
+
)
|
|
637
|
+
dialog.clear()
|
|
638
|
+
},
|
|
639
|
+
},
|
|
640
|
+
{
|
|
641
|
+
title: "Redo",
|
|
642
|
+
value: "session.redo",
|
|
643
|
+
category: "Session",
|
|
644
|
+
enabled: !!session()?.revert?.messageID,
|
|
645
|
+
slash: {
|
|
646
|
+
name: "redo",
|
|
647
|
+
},
|
|
648
|
+
run: () => {
|
|
649
|
+
dialog.clear()
|
|
650
|
+
const messageID = session()?.revert?.messageID
|
|
651
|
+
if (!messageID) return
|
|
652
|
+
const message = messages().find((x) => x.role === "user" && x.id > messageID)
|
|
653
|
+
if (!message) {
|
|
654
|
+
void sdk.client.session.unrevert({
|
|
655
|
+
sessionID: route.sessionID,
|
|
656
|
+
})
|
|
657
|
+
prompt?.set({ input: "", parts: [] })
|
|
658
|
+
return
|
|
659
|
+
}
|
|
660
|
+
void sdk.client.session.revert({
|
|
661
|
+
sessionID: route.sessionID,
|
|
662
|
+
messageID: message.id,
|
|
663
|
+
})
|
|
664
|
+
},
|
|
665
|
+
},
|
|
666
|
+
{
|
|
667
|
+
title: sidebarVisible() ? "Hide sidebar" : "Show sidebar",
|
|
668
|
+
value: "session.sidebar.toggle",
|
|
669
|
+
category: "Session",
|
|
670
|
+
run: () => {
|
|
671
|
+
batch(() => {
|
|
672
|
+
const isVisible = sidebarVisible()
|
|
673
|
+
setSidebar(() => (isVisible ? "hide" : "auto"))
|
|
674
|
+
setSidebarOpen(!isVisible)
|
|
675
|
+
})
|
|
676
|
+
dialog.clear()
|
|
677
|
+
},
|
|
678
|
+
},
|
|
679
|
+
{
|
|
680
|
+
title: conceal() ? "Disable code concealment" : "Enable code concealment",
|
|
681
|
+
value: "session.toggle.conceal",
|
|
682
|
+
category: "Session",
|
|
683
|
+
run: () => {
|
|
684
|
+
setConceal((prev) => !prev)
|
|
685
|
+
dialog.clear()
|
|
686
|
+
},
|
|
687
|
+
},
|
|
688
|
+
{
|
|
689
|
+
title: showTimestamps() ? "Hide timestamps" : "Show timestamps",
|
|
690
|
+
value: "session.toggle.timestamps",
|
|
691
|
+
category: "Session",
|
|
692
|
+
slash: {
|
|
693
|
+
name: "timestamps",
|
|
694
|
+
aliases: ["toggle-timestamps"],
|
|
695
|
+
},
|
|
696
|
+
run: () => {
|
|
697
|
+
setTimestamps((prev) => (prev === "show" ? "hide" : "show"))
|
|
698
|
+
dialog.clear()
|
|
699
|
+
},
|
|
700
|
+
},
|
|
701
|
+
{
|
|
702
|
+
title: (() => {
|
|
703
|
+
const next = nextThinkingMode(thinkingMode())
|
|
704
|
+
if (next === "hide") return "Collapse thinking"
|
|
705
|
+
return "Expand thinking"
|
|
706
|
+
})(),
|
|
707
|
+
value: "session.toggle.thinking",
|
|
708
|
+
category: "Session",
|
|
709
|
+
slash: {
|
|
710
|
+
name: "thinking",
|
|
711
|
+
aliases: ["toggle-thinking"],
|
|
712
|
+
},
|
|
713
|
+
run: () => {
|
|
714
|
+
thinking.set(nextThinkingMode(thinkingMode()))
|
|
715
|
+
dialog.clear()
|
|
716
|
+
},
|
|
717
|
+
},
|
|
718
|
+
{
|
|
719
|
+
title: showDetails() ? "Hide tool details" : "Show tool details",
|
|
720
|
+
value: "session.toggle.actions",
|
|
721
|
+
category: "Session",
|
|
722
|
+
run: () => {
|
|
723
|
+
setShowDetails((prev) => !prev)
|
|
724
|
+
dialog.clear()
|
|
725
|
+
},
|
|
726
|
+
},
|
|
727
|
+
{
|
|
728
|
+
title: "Toggle session scrollbar",
|
|
729
|
+
value: "session.toggle.scrollbar",
|
|
730
|
+
category: "Session",
|
|
731
|
+
run: () => {
|
|
732
|
+
setShowScrollbar((prev) => !prev)
|
|
733
|
+
dialog.clear()
|
|
734
|
+
},
|
|
735
|
+
},
|
|
736
|
+
{
|
|
737
|
+
title: showGenericToolOutput() ? "Hide generic tool output" : "Show generic tool output",
|
|
738
|
+
value: "session.toggle.generic_tool_output",
|
|
739
|
+
category: "Session",
|
|
740
|
+
run: () => {
|
|
741
|
+
setShowGenericToolOutput((prev) => !prev)
|
|
742
|
+
dialog.clear()
|
|
743
|
+
},
|
|
744
|
+
},
|
|
745
|
+
{
|
|
746
|
+
title: "Page up",
|
|
747
|
+
value: "session.page.up",
|
|
748
|
+
category: "Session",
|
|
749
|
+
hidden: true,
|
|
750
|
+
run: () => {
|
|
751
|
+
scroll.scrollBy(-scroll.height / 2)
|
|
752
|
+
dialog.clear()
|
|
753
|
+
},
|
|
754
|
+
},
|
|
755
|
+
{
|
|
756
|
+
title: "Page down",
|
|
757
|
+
value: "session.page.down",
|
|
758
|
+
category: "Session",
|
|
759
|
+
hidden: true,
|
|
760
|
+
run: () => {
|
|
761
|
+
scroll.scrollBy(scroll.height / 2)
|
|
762
|
+
dialog.clear()
|
|
763
|
+
},
|
|
764
|
+
},
|
|
765
|
+
{
|
|
766
|
+
title: "Line up",
|
|
767
|
+
value: "session.line.up",
|
|
768
|
+
category: "Session",
|
|
769
|
+
hidden: true,
|
|
770
|
+
run: () => {
|
|
771
|
+
scroll.scrollBy(-1)
|
|
772
|
+
dialog.clear()
|
|
773
|
+
},
|
|
774
|
+
},
|
|
775
|
+
{
|
|
776
|
+
title: "Line down",
|
|
777
|
+
value: "session.line.down",
|
|
778
|
+
category: "Session",
|
|
779
|
+
hidden: true,
|
|
780
|
+
run: () => {
|
|
781
|
+
scroll.scrollBy(1)
|
|
782
|
+
dialog.clear()
|
|
783
|
+
},
|
|
784
|
+
},
|
|
785
|
+
{
|
|
786
|
+
title: "Half page up",
|
|
787
|
+
value: "session.half.page.up",
|
|
788
|
+
category: "Session",
|
|
789
|
+
hidden: true,
|
|
790
|
+
run: () => {
|
|
791
|
+
scroll.scrollBy(-scroll.height / 4)
|
|
792
|
+
dialog.clear()
|
|
793
|
+
},
|
|
794
|
+
},
|
|
795
|
+
{
|
|
796
|
+
title: "Half page down",
|
|
797
|
+
value: "session.half.page.down",
|
|
798
|
+
category: "Session",
|
|
799
|
+
hidden: true,
|
|
800
|
+
run: () => {
|
|
801
|
+
scroll.scrollBy(scroll.height / 4)
|
|
802
|
+
dialog.clear()
|
|
803
|
+
},
|
|
804
|
+
},
|
|
805
|
+
{
|
|
806
|
+
title: "First message",
|
|
807
|
+
value: "session.first",
|
|
808
|
+
category: "Session",
|
|
809
|
+
hidden: true,
|
|
810
|
+
run: () => {
|
|
811
|
+
scroll.scrollTo(0)
|
|
812
|
+
dialog.clear()
|
|
813
|
+
},
|
|
814
|
+
},
|
|
815
|
+
{
|
|
816
|
+
title: "Last message",
|
|
817
|
+
value: "session.last",
|
|
818
|
+
category: "Session",
|
|
819
|
+
hidden: true,
|
|
820
|
+
run: () => {
|
|
821
|
+
scroll.scrollTo(scroll.scrollHeight)
|
|
822
|
+
dialog.clear()
|
|
823
|
+
},
|
|
824
|
+
},
|
|
825
|
+
{
|
|
826
|
+
title: "Jump to last user message",
|
|
827
|
+
value: "session.messages_last_user",
|
|
828
|
+
category: "Session",
|
|
829
|
+
hidden: true,
|
|
830
|
+
run: () => {
|
|
831
|
+
const messages = sync.data.message[route.sessionID]
|
|
832
|
+
if (!messages || !messages.length) return
|
|
833
|
+
|
|
834
|
+
// Find the most recent user message with non-ignored, non-synthetic text parts
|
|
835
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
836
|
+
const message = messages[i]
|
|
837
|
+
if (!message || message.role !== "user") continue
|
|
838
|
+
|
|
839
|
+
const parts = sync.data.part[message.id]
|
|
840
|
+
if (!parts || !Array.isArray(parts)) continue
|
|
841
|
+
|
|
842
|
+
const hasValidTextPart = parts.some(
|
|
843
|
+
(part) => part && part.type === "text" && !part.synthetic && !part.ignored,
|
|
844
|
+
)
|
|
845
|
+
|
|
846
|
+
if (hasValidTextPart) {
|
|
847
|
+
const child = scroll.getChildren().find((child) => {
|
|
848
|
+
return child.id === message.id
|
|
849
|
+
})
|
|
850
|
+
if (child) scroll.scrollBy(child.y - scroll.y - 1)
|
|
851
|
+
break
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
},
|
|
855
|
+
},
|
|
856
|
+
{
|
|
857
|
+
title: "Next message",
|
|
858
|
+
value: "session.message.next",
|
|
859
|
+
category: "Session",
|
|
860
|
+
hidden: true,
|
|
861
|
+
run: () => scrollToMessage("next", dialog),
|
|
862
|
+
},
|
|
863
|
+
{
|
|
864
|
+
title: "Previous message",
|
|
865
|
+
value: "session.message.previous",
|
|
866
|
+
category: "Session",
|
|
867
|
+
hidden: true,
|
|
868
|
+
run: () => scrollToMessage("prev", dialog),
|
|
869
|
+
},
|
|
870
|
+
{
|
|
871
|
+
title: "Copy last assistant message",
|
|
872
|
+
value: "messages.copy",
|
|
873
|
+
category: "Session",
|
|
874
|
+
run: () => {
|
|
875
|
+
const revertID = session()?.revert?.messageID
|
|
876
|
+
const lastAssistantMessage = messages().findLast(
|
|
877
|
+
(msg) => msg.role === "assistant" && (!revertID || msg.id < revertID),
|
|
878
|
+
)
|
|
879
|
+
if (!lastAssistantMessage) {
|
|
880
|
+
toast.show({ message: "No assistant messages found", variant: "error" })
|
|
881
|
+
dialog.clear()
|
|
882
|
+
return
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
const parts = sync.data.part[lastAssistantMessage.id] ?? []
|
|
886
|
+
const textParts = parts.filter((part) => part.type === "text")
|
|
887
|
+
if (textParts.length === 0) {
|
|
888
|
+
toast.show({ message: "No text parts found in last assistant message", variant: "error" })
|
|
889
|
+
dialog.clear()
|
|
890
|
+
return
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
const text = textParts
|
|
894
|
+
.map((part) => part.text)
|
|
895
|
+
.join("\n")
|
|
896
|
+
.trim()
|
|
897
|
+
if (!text) {
|
|
898
|
+
toast.show({
|
|
899
|
+
message: "No text content found in last assistant message",
|
|
900
|
+
variant: "error",
|
|
901
|
+
})
|
|
902
|
+
dialog.clear()
|
|
903
|
+
return
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
clipboard
|
|
907
|
+
.write?.(text)
|
|
908
|
+
.then(() => toast.show({ message: "Message copied to clipboard!", variant: "success" }))
|
|
909
|
+
.catch(() => toast.show({ message: "Failed to copy to clipboard", variant: "error" }))
|
|
910
|
+
dialog.clear()
|
|
911
|
+
},
|
|
912
|
+
},
|
|
913
|
+
{
|
|
914
|
+
title: "Copy session transcript",
|
|
915
|
+
value: "session.copy",
|
|
916
|
+
category: "Session",
|
|
917
|
+
slash: {
|
|
918
|
+
name: "copy",
|
|
919
|
+
},
|
|
920
|
+
run: async () => {
|
|
921
|
+
try {
|
|
922
|
+
const sessionData = session()
|
|
923
|
+
if (!sessionData) return
|
|
924
|
+
const sessionMessages = messages()
|
|
925
|
+
const transcript = formatTranscript(
|
|
926
|
+
sessionData,
|
|
927
|
+
sessionMessages.map((msg) => ({ info: msg, parts: sync.data.part[msg.id] ?? [] })),
|
|
928
|
+
{
|
|
929
|
+
thinking: showThinking(),
|
|
930
|
+
toolDetails: showDetails(),
|
|
931
|
+
assistantMetadata: showAssistantMetadata(),
|
|
932
|
+
providers: sync.data.provider,
|
|
933
|
+
},
|
|
934
|
+
)
|
|
935
|
+
await clipboard.write?.(transcript)
|
|
936
|
+
toast.show({ message: "Session transcript copied to clipboard!", variant: "success" })
|
|
937
|
+
} catch {
|
|
938
|
+
toast.show({ message: "Failed to copy session transcript", variant: "error" })
|
|
939
|
+
}
|
|
940
|
+
dialog.clear()
|
|
941
|
+
},
|
|
942
|
+
},
|
|
943
|
+
{
|
|
944
|
+
title: "Export session transcript",
|
|
945
|
+
value: "session.export",
|
|
946
|
+
category: "Session",
|
|
947
|
+
slash: {
|
|
948
|
+
name: "export",
|
|
949
|
+
},
|
|
950
|
+
run: async () => {
|
|
951
|
+
try {
|
|
952
|
+
const sessionData = session()
|
|
953
|
+
if (!sessionData) return
|
|
954
|
+
const sessionMessages = messages()
|
|
955
|
+
|
|
956
|
+
const defaultFilename = `session-${sessionData.id.slice(0, 8)}.md`
|
|
957
|
+
|
|
958
|
+
const options = await DialogExportOptions.show(
|
|
959
|
+
dialog,
|
|
960
|
+
defaultFilename,
|
|
961
|
+
showThinking(),
|
|
962
|
+
showDetails(),
|
|
963
|
+
showAssistantMetadata(),
|
|
964
|
+
false,
|
|
965
|
+
)
|
|
966
|
+
|
|
967
|
+
if (options === null) return
|
|
968
|
+
|
|
969
|
+
const transcript = formatTranscript(
|
|
970
|
+
sessionData,
|
|
971
|
+
sessionMessages.map((msg) => ({ info: msg, parts: sync.data.part[msg.id] ?? [] })),
|
|
972
|
+
{
|
|
973
|
+
thinking: options.thinking,
|
|
974
|
+
toolDetails: options.toolDetails,
|
|
975
|
+
assistantMetadata: options.assistantMetadata,
|
|
976
|
+
providers: sync.data.provider,
|
|
977
|
+
},
|
|
978
|
+
)
|
|
979
|
+
|
|
980
|
+
if (options.openWithoutSaving) {
|
|
981
|
+
// Just open in editor without saving
|
|
982
|
+
await openEditor({
|
|
983
|
+
renderer,
|
|
984
|
+
value: transcript,
|
|
985
|
+
cwd:
|
|
986
|
+
(project.instance.path().worktree === "/" ? undefined : project.instance.path().worktree) ||
|
|
987
|
+
project.instance.directory() ||
|
|
988
|
+
paths.cwd,
|
|
989
|
+
})
|
|
990
|
+
} else {
|
|
991
|
+
const exportDir = paths.cwd
|
|
992
|
+
const filename = options.filename.trim()
|
|
993
|
+
const filepath = path.join(exportDir, filename)
|
|
994
|
+
|
|
995
|
+
await writeExport(filepath, transcript)
|
|
996
|
+
|
|
997
|
+
// Open with EDITOR if available
|
|
998
|
+
const result = await openEditor({
|
|
999
|
+
renderer,
|
|
1000
|
+
value: transcript,
|
|
1001
|
+
cwd:
|
|
1002
|
+
(project.instance.path().worktree === "/" ? undefined : project.instance.path().worktree) ||
|
|
1003
|
+
project.instance.directory() ||
|
|
1004
|
+
paths.cwd,
|
|
1005
|
+
})
|
|
1006
|
+
if (result !== undefined) {
|
|
1007
|
+
await writeExport(filepath, result)
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
toast.show({ message: `Session exported to ${filename}`, variant: "success" })
|
|
1011
|
+
}
|
|
1012
|
+
} catch {
|
|
1013
|
+
toast.show({ message: "Failed to export session", variant: "error" })
|
|
1014
|
+
}
|
|
1015
|
+
dialog.clear()
|
|
1016
|
+
},
|
|
1017
|
+
},
|
|
1018
|
+
{
|
|
1019
|
+
title: "Background subagents",
|
|
1020
|
+
value: "session.background",
|
|
1021
|
+
category: "Session",
|
|
1022
|
+
hidden: true,
|
|
1023
|
+
enabled: foregroundTasks().length > 0,
|
|
1024
|
+
run: () => {
|
|
1025
|
+
void sdk.client.experimental.session.background({
|
|
1026
|
+
sessionID: route.sessionID,
|
|
1027
|
+
workspace: project.workspace.current(),
|
|
1028
|
+
})
|
|
1029
|
+
dialog.clear()
|
|
1030
|
+
},
|
|
1031
|
+
},
|
|
1032
|
+
{
|
|
1033
|
+
title: "Go to child session",
|
|
1034
|
+
value: "session.child.first",
|
|
1035
|
+
category: "Session",
|
|
1036
|
+
hidden: true,
|
|
1037
|
+
run: () => {
|
|
1038
|
+
dialog.clear()
|
|
1039
|
+
moveFirstChild()
|
|
1040
|
+
},
|
|
1041
|
+
},
|
|
1042
|
+
{
|
|
1043
|
+
title: "Go to parent session",
|
|
1044
|
+
value: "session.parent",
|
|
1045
|
+
category: "Session",
|
|
1046
|
+
hidden: true,
|
|
1047
|
+
enabled: !!session()?.parentID,
|
|
1048
|
+
run: childSessionHandler(() => {
|
|
1049
|
+
const parentID = session()?.parentID
|
|
1050
|
+
if (parentID) {
|
|
1051
|
+
navigate({
|
|
1052
|
+
type: "session",
|
|
1053
|
+
sessionID: parentID,
|
|
1054
|
+
})
|
|
1055
|
+
}
|
|
1056
|
+
dialog.clear()
|
|
1057
|
+
}),
|
|
1058
|
+
},
|
|
1059
|
+
{
|
|
1060
|
+
title: "Next child session",
|
|
1061
|
+
value: "session.child.next",
|
|
1062
|
+
category: "Session",
|
|
1063
|
+
hidden: true,
|
|
1064
|
+
enabled: !!session()?.parentID,
|
|
1065
|
+
run: childSessionHandler(() => {
|
|
1066
|
+
dialog.clear()
|
|
1067
|
+
moveChild(1)
|
|
1068
|
+
}),
|
|
1069
|
+
},
|
|
1070
|
+
{
|
|
1071
|
+
title: "Previous child session",
|
|
1072
|
+
value: "session.child.previous",
|
|
1073
|
+
category: "Session",
|
|
1074
|
+
hidden: true,
|
|
1075
|
+
enabled: !!session()?.parentID,
|
|
1076
|
+
run: childSessionHandler(() => {
|
|
1077
|
+
dialog.clear()
|
|
1078
|
+
moveChild(-1)
|
|
1079
|
+
}),
|
|
1080
|
+
},
|
|
1081
|
+
])
|
|
1082
|
+
|
|
1083
|
+
const sessionCommands = createMemo(() =>
|
|
1084
|
+
sessionCommandList().map((command) => ({
|
|
1085
|
+
namespace: "palette",
|
|
1086
|
+
name: command.value,
|
|
1087
|
+
desc: "description" in command ? command.description : undefined,
|
|
1088
|
+
slashName: "slash" in command ? command.slash?.name : undefined,
|
|
1089
|
+
slashAliases: "slash" in command ? command.slash?.aliases : undefined,
|
|
1090
|
+
...command,
|
|
1091
|
+
})),
|
|
1092
|
+
)
|
|
1093
|
+
|
|
1094
|
+
useBindings(() => ({
|
|
1095
|
+
commands: sessionCommands(),
|
|
1096
|
+
}))
|
|
1097
|
+
|
|
1098
|
+
useBindings(() => ({
|
|
1099
|
+
bindings: tuiConfig.keybinds.gather("session.global", sessionGlobalBindingCommands),
|
|
1100
|
+
}))
|
|
1101
|
+
|
|
1102
|
+
useBindings(() => ({
|
|
1103
|
+
enabled: () => renderer.currentFocusedEditor === null,
|
|
1104
|
+
bindings: tuiConfig.keybinds.gather("session.global.unfocused", sessionGlobalUnfocusedBindingCommands),
|
|
1105
|
+
}))
|
|
1106
|
+
|
|
1107
|
+
useBindings(() => ({
|
|
1108
|
+
mode: NEUROCODE_BASE_MODE,
|
|
1109
|
+
bindings: tuiConfig.keybinds.gather("session", sessionBindingCommands),
|
|
1110
|
+
}))
|
|
1111
|
+
|
|
1112
|
+
useBindings(() => ({
|
|
1113
|
+
mode: NEUROCODE_BASE_MODE,
|
|
1114
|
+
enabled: foregroundTasks().length > 0,
|
|
1115
|
+
priority: 1,
|
|
1116
|
+
bindings: tuiConfig.keybinds.get("session.background"),
|
|
1117
|
+
}))
|
|
1118
|
+
|
|
1119
|
+
const revertInfo = createMemo(() => session()?.revert)
|
|
1120
|
+
const revertMessageID = createMemo(() => revertInfo()?.messageID)
|
|
1121
|
+
|
|
1122
|
+
const revertDiffFiles = createMemo(() => getRevertDiffFiles(revertInfo()?.diff ?? ""))
|
|
1123
|
+
|
|
1124
|
+
const revertRevertedMessages = createMemo(() => {
|
|
1125
|
+
const messageID = revertMessageID()
|
|
1126
|
+
if (!messageID) return []
|
|
1127
|
+
return messages().filter((x) => x.id >= messageID && x.role === "user")
|
|
1128
|
+
})
|
|
1129
|
+
|
|
1130
|
+
const revert = createMemo(() => {
|
|
1131
|
+
const info = revertInfo()
|
|
1132
|
+
if (!info) return
|
|
1133
|
+
if (!info.messageID) return
|
|
1134
|
+
return {
|
|
1135
|
+
messageID: info.messageID,
|
|
1136
|
+
reverted: revertRevertedMessages(),
|
|
1137
|
+
diff: info.diff,
|
|
1138
|
+
diffFiles: revertDiffFiles(),
|
|
1139
|
+
}
|
|
1140
|
+
})
|
|
1141
|
+
|
|
1142
|
+
// snap to bottom when session changes
|
|
1143
|
+
createEffect(on(() => route.sessionID, toBottom))
|
|
1144
|
+
|
|
1145
|
+
return (
|
|
1146
|
+
<LocationProvider location={location()}>
|
|
1147
|
+
<context.Provider
|
|
1148
|
+
value={{
|
|
1149
|
+
get width() {
|
|
1150
|
+
return contentWidth()
|
|
1151
|
+
},
|
|
1152
|
+
sessionID: route.sessionID,
|
|
1153
|
+
conceal,
|
|
1154
|
+
thinkingMode,
|
|
1155
|
+
showThinking,
|
|
1156
|
+
showTimestamps,
|
|
1157
|
+
showDetails,
|
|
1158
|
+
showGenericToolOutput,
|
|
1159
|
+
diffWrapMode,
|
|
1160
|
+
providers,
|
|
1161
|
+
sync,
|
|
1162
|
+
tui: tuiConfig,
|
|
1163
|
+
}}
|
|
1164
|
+
>
|
|
1165
|
+
<box flexDirection="row" flexGrow={1} minHeight={0}>
|
|
1166
|
+
<box flexGrow={1} minHeight={0} paddingBottom={1} paddingLeft={2} paddingRight={2} gap={1}>
|
|
1167
|
+
<Show when={session()}>
|
|
1168
|
+
<scrollbox
|
|
1169
|
+
ref={(r) => (scroll = r)}
|
|
1170
|
+
viewportOptions={{
|
|
1171
|
+
paddingRight: showScrollbar() ? 1 : 0,
|
|
1172
|
+
}}
|
|
1173
|
+
verticalScrollbarOptions={{
|
|
1174
|
+
paddingLeft: 1,
|
|
1175
|
+
visible: showScrollbar(),
|
|
1176
|
+
trackOptions: {
|
|
1177
|
+
backgroundColor: theme.backgroundElement,
|
|
1178
|
+
foregroundColor: theme.border,
|
|
1179
|
+
},
|
|
1180
|
+
}}
|
|
1181
|
+
stickyScroll={true}
|
|
1182
|
+
stickyStart="bottom"
|
|
1183
|
+
flexGrow={1}
|
|
1184
|
+
scrollAcceleration={scrollAcceleration()}
|
|
1185
|
+
>
|
|
1186
|
+
<box height={1} />
|
|
1187
|
+
<For each={messages()}>
|
|
1188
|
+
{(message, index) => (
|
|
1189
|
+
<Switch>
|
|
1190
|
+
<Match when={message.id === revert()?.messageID}>
|
|
1191
|
+
{(function () {
|
|
1192
|
+
const redoShortcut = useCommandShortcut("session.redo")
|
|
1193
|
+
const [hover, setHover] = createSignal(false)
|
|
1194
|
+
const dialog = useDialog()
|
|
1195
|
+
|
|
1196
|
+
const handleUnrevert = async () => {
|
|
1197
|
+
const confirmed = await DialogConfirm.show(
|
|
1198
|
+
dialog,
|
|
1199
|
+
"Confirm Redo",
|
|
1200
|
+
"Are you sure you want to restore the reverted messages?",
|
|
1201
|
+
)
|
|
1202
|
+
if (confirmed) {
|
|
1203
|
+
keymap.dispatchCommand("session.redo")
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
return (
|
|
1208
|
+
<box
|
|
1209
|
+
onMouseOver={() => setHover(true)}
|
|
1210
|
+
onMouseOut={() => setHover(false)}
|
|
1211
|
+
onMouseUp={handleUnrevert}
|
|
1212
|
+
marginTop={1}
|
|
1213
|
+
flexShrink={0}
|
|
1214
|
+
border={["left"]}
|
|
1215
|
+
customBorderChars={SplitBorder.customBorderChars}
|
|
1216
|
+
borderColor={theme.backgroundPanel}
|
|
1217
|
+
>
|
|
1218
|
+
<box
|
|
1219
|
+
paddingTop={1}
|
|
1220
|
+
paddingBottom={1}
|
|
1221
|
+
paddingLeft={2}
|
|
1222
|
+
backgroundColor={hover() ? theme.backgroundElement : theme.backgroundPanel}
|
|
1223
|
+
>
|
|
1224
|
+
<text fg={theme.textMuted}>{revert()!.reverted.length} message reverted</text>
|
|
1225
|
+
<text fg={theme.textMuted}>
|
|
1226
|
+
<span style={{ fg: theme.text }}>{redoShortcut()}</span> or /redo to restore
|
|
1227
|
+
</text>
|
|
1228
|
+
<Show when={revert()!.diffFiles?.length}>
|
|
1229
|
+
<box marginTop={1}>
|
|
1230
|
+
<For each={revert()!.diffFiles}>
|
|
1231
|
+
{(file) => (
|
|
1232
|
+
<text fg={theme.text}>
|
|
1233
|
+
{file.filename}
|
|
1234
|
+
<Show when={file.additions > 0}>
|
|
1235
|
+
<span style={{ fg: theme.diffAdded }}> +{file.additions}</span>
|
|
1236
|
+
</Show>
|
|
1237
|
+
<Show when={file.deletions > 0}>
|
|
1238
|
+
<span style={{ fg: theme.diffRemoved }}> -{file.deletions}</span>
|
|
1239
|
+
</Show>
|
|
1240
|
+
</text>
|
|
1241
|
+
)}
|
|
1242
|
+
</For>
|
|
1243
|
+
</box>
|
|
1244
|
+
</Show>
|
|
1245
|
+
</box>
|
|
1246
|
+
</box>
|
|
1247
|
+
)
|
|
1248
|
+
})()}
|
|
1249
|
+
</Match>
|
|
1250
|
+
<Match when={revert()?.messageID && message.id >= revert()!.messageID}>
|
|
1251
|
+
<></>
|
|
1252
|
+
</Match>
|
|
1253
|
+
<Match when={message.role === "user"}>
|
|
1254
|
+
<UserMessage
|
|
1255
|
+
index={index()}
|
|
1256
|
+
onMouseUp={() => {
|
|
1257
|
+
if (renderer.getSelection()?.getSelectedText()) return
|
|
1258
|
+
dialog.replace(() => (
|
|
1259
|
+
<DialogMessage
|
|
1260
|
+
messageID={message.id}
|
|
1261
|
+
sessionID={route.sessionID}
|
|
1262
|
+
setPrompt={(promptInfo) => prompt?.set(promptInfo)}
|
|
1263
|
+
/>
|
|
1264
|
+
))
|
|
1265
|
+
}}
|
|
1266
|
+
message={message as UserMessage}
|
|
1267
|
+
parts={sync.data.part[message.id] ?? []}
|
|
1268
|
+
pending={pending()}
|
|
1269
|
+
/>
|
|
1270
|
+
</Match>
|
|
1271
|
+
<Match when={message.role === "assistant"}>
|
|
1272
|
+
<AssistantMessage
|
|
1273
|
+
last={lastAssistant()?.id === message.id}
|
|
1274
|
+
message={message as AssistantMessage}
|
|
1275
|
+
parts={sync.data.part[message.id] ?? []}
|
|
1276
|
+
/>
|
|
1277
|
+
</Match>
|
|
1278
|
+
</Switch>
|
|
1279
|
+
)}
|
|
1280
|
+
</For>
|
|
1281
|
+
</scrollbox>
|
|
1282
|
+
<box flexShrink={0}>
|
|
1283
|
+
<Show when={permissions().length > 0}>
|
|
1284
|
+
<PermissionPrompt
|
|
1285
|
+
request={permissions()[0]}
|
|
1286
|
+
directory={sync.session.get(permissions()[0].sessionID)?.directory}
|
|
1287
|
+
/>
|
|
1288
|
+
</Show>
|
|
1289
|
+
<Show when={permissions().length === 0 && questions().length > 0}>
|
|
1290
|
+
<QuestionPrompt
|
|
1291
|
+
request={questions()[0]}
|
|
1292
|
+
directory={sync.session.get(questions()[0].sessionID)?.directory}
|
|
1293
|
+
/>
|
|
1294
|
+
</Show>
|
|
1295
|
+
<Show when={session()?.parentID}>
|
|
1296
|
+
<SubagentFooter />
|
|
1297
|
+
</Show>
|
|
1298
|
+
<Show when={visible()}>
|
|
1299
|
+
<pluginRuntime.Slot
|
|
1300
|
+
name="session_prompt"
|
|
1301
|
+
mode="replace"
|
|
1302
|
+
session_id={route.sessionID}
|
|
1303
|
+
visible={visible()}
|
|
1304
|
+
disabled={disabled()}
|
|
1305
|
+
on_submit={toBottom}
|
|
1306
|
+
ref={bind}
|
|
1307
|
+
>
|
|
1308
|
+
<Prompt
|
|
1309
|
+
visible={visible()}
|
|
1310
|
+
ref={bind}
|
|
1311
|
+
disabled={disabled()}
|
|
1312
|
+
onSubmit={() => {
|
|
1313
|
+
toBottom()
|
|
1314
|
+
}}
|
|
1315
|
+
sessionID={route.sessionID}
|
|
1316
|
+
right={<pluginRuntime.Slot name="session_prompt_right" session_id={route.sessionID} />}
|
|
1317
|
+
/>
|
|
1318
|
+
</pluginRuntime.Slot>
|
|
1319
|
+
</Show>
|
|
1320
|
+
</box>
|
|
1321
|
+
</Show>
|
|
1322
|
+
<Toast />
|
|
1323
|
+
</box>
|
|
1324
|
+
<Show when={sidebarVisible()}>
|
|
1325
|
+
<Switch>
|
|
1326
|
+
<Match when={wide()}>
|
|
1327
|
+
<Sidebar sessionID={route.sessionID} />
|
|
1328
|
+
</Match>
|
|
1329
|
+
<Match when={!wide()}>
|
|
1330
|
+
<box
|
|
1331
|
+
position="absolute"
|
|
1332
|
+
top={0}
|
|
1333
|
+
left={0}
|
|
1334
|
+
right={0}
|
|
1335
|
+
bottom={0}
|
|
1336
|
+
alignItems="flex-end"
|
|
1337
|
+
backgroundColor={RGBA.fromInts(0, 0, 0, 70)}
|
|
1338
|
+
>
|
|
1339
|
+
<Sidebar sessionID={route.sessionID} />
|
|
1340
|
+
</box>
|
|
1341
|
+
</Match>
|
|
1342
|
+
</Switch>
|
|
1343
|
+
</Show>
|
|
1344
|
+
</box>
|
|
1345
|
+
</context.Provider>
|
|
1346
|
+
</LocationProvider>
|
|
1347
|
+
)
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
function UserMessage(props: {
|
|
1351
|
+
message: UserMessage
|
|
1352
|
+
parts: Part[]
|
|
1353
|
+
onMouseUp: () => void
|
|
1354
|
+
index: number
|
|
1355
|
+
pending?: string
|
|
1356
|
+
}) {
|
|
1357
|
+
const ctx = use()
|
|
1358
|
+
const local = useLocal()
|
|
1359
|
+
const text = createMemo(() => {
|
|
1360
|
+
const texts = props.parts
|
|
1361
|
+
.map((x) => {
|
|
1362
|
+
if (x.type === "text" && !x.synthetic) {
|
|
1363
|
+
return x.text
|
|
1364
|
+
}
|
|
1365
|
+
return null
|
|
1366
|
+
})
|
|
1367
|
+
.filter(Boolean)
|
|
1368
|
+
return texts.join("\n\n")
|
|
1369
|
+
})
|
|
1370
|
+
const files = createMemo(() => props.parts.flatMap((x) => (x.type === "file" ? [x] : [])))
|
|
1371
|
+
const { theme } = useTheme()
|
|
1372
|
+
const [hover, setHover] = createSignal(false)
|
|
1373
|
+
const queued = createMemo(() => props.pending && props.message.id > props.pending)
|
|
1374
|
+
const color = createMemo(() => local.agent.color(props.message.agent))
|
|
1375
|
+
const queuedFg = createMemo(() => selectedForeground(theme, color()))
|
|
1376
|
+
const metadataVisible = createMemo(() => queued() || ctx.showTimestamps())
|
|
1377
|
+
|
|
1378
|
+
const compaction = createMemo(() => props.parts.find((x) => x.type === "compaction"))
|
|
1379
|
+
|
|
1380
|
+
return (
|
|
1381
|
+
<>
|
|
1382
|
+
<Show when={text()}>
|
|
1383
|
+
<box
|
|
1384
|
+
id={props.message.id}
|
|
1385
|
+
ref={(el: BoxRenderable) => alwaysSeparate.add(el)}
|
|
1386
|
+
border={["left"]}
|
|
1387
|
+
borderColor={color()}
|
|
1388
|
+
customBorderChars={SplitBorder.customBorderChars}
|
|
1389
|
+
marginTop={props.index === 0 ? 0 : 1}
|
|
1390
|
+
>
|
|
1391
|
+
<box
|
|
1392
|
+
onMouseOver={() => {
|
|
1393
|
+
setHover(true)
|
|
1394
|
+
}}
|
|
1395
|
+
onMouseOut={() => {
|
|
1396
|
+
setHover(false)
|
|
1397
|
+
}}
|
|
1398
|
+
onMouseUp={props.onMouseUp}
|
|
1399
|
+
paddingTop={1}
|
|
1400
|
+
paddingBottom={1}
|
|
1401
|
+
paddingLeft={2}
|
|
1402
|
+
backgroundColor={hover() ? theme.backgroundElement : theme.backgroundPanel}
|
|
1403
|
+
flexShrink={0}
|
|
1404
|
+
>
|
|
1405
|
+
<text fg={theme.text}>{text()}</text>
|
|
1406
|
+
<Show when={files().length}>
|
|
1407
|
+
<box flexDirection="row" paddingBottom={metadataVisible() ? 1 : 0} paddingTop={1} gap={1} flexWrap="wrap">
|
|
1408
|
+
<For each={files()}>
|
|
1409
|
+
{(file) => {
|
|
1410
|
+
const directory = file.mime === "application/x-directory"
|
|
1411
|
+
return (
|
|
1412
|
+
<text fg={theme.text}>
|
|
1413
|
+
<span style={{ bg: theme.secondary, fg: theme.background }}>
|
|
1414
|
+
{directory ? " Directory " : " File "}
|
|
1415
|
+
</span>
|
|
1416
|
+
<span style={{ bg: theme.backgroundElement, fg: theme.textMuted }}> {file.filename} </span>
|
|
1417
|
+
</text>
|
|
1418
|
+
)
|
|
1419
|
+
}}
|
|
1420
|
+
</For>
|
|
1421
|
+
</box>
|
|
1422
|
+
</Show>
|
|
1423
|
+
<Show
|
|
1424
|
+
when={queued()}
|
|
1425
|
+
fallback={
|
|
1426
|
+
<Show when={ctx.showTimestamps()}>
|
|
1427
|
+
<text fg={theme.textMuted}>
|
|
1428
|
+
<span style={{ fg: theme.textMuted }}>
|
|
1429
|
+
{Locale.todayTimeOrDateTime(props.message.time.created)}
|
|
1430
|
+
</span>
|
|
1431
|
+
</text>
|
|
1432
|
+
</Show>
|
|
1433
|
+
}
|
|
1434
|
+
>
|
|
1435
|
+
<text fg={theme.textMuted}>
|
|
1436
|
+
<span style={{ bg: color(), fg: queuedFg(), bold: true }}> QUEUED </span>
|
|
1437
|
+
</text>
|
|
1438
|
+
</Show>
|
|
1439
|
+
</box>
|
|
1440
|
+
</box>
|
|
1441
|
+
</Show>
|
|
1442
|
+
<Show when={compaction()}>
|
|
1443
|
+
<box
|
|
1444
|
+
marginTop={1}
|
|
1445
|
+
border={["top"]}
|
|
1446
|
+
title=" Compaction "
|
|
1447
|
+
titleAlignment="center"
|
|
1448
|
+
borderColor={theme.borderActive}
|
|
1449
|
+
/>
|
|
1450
|
+
</Show>
|
|
1451
|
+
</>
|
|
1452
|
+
)
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; last: boolean }) {
|
|
1456
|
+
const ctx = use()
|
|
1457
|
+
const local = useLocal()
|
|
1458
|
+
const { theme } = useTheme()
|
|
1459
|
+
const sync = useSync()
|
|
1460
|
+
const messages = createMemo(() => sync.data.message[props.message.sessionID] ?? [])
|
|
1461
|
+
const model = createMemo(() => Model.name(ctx.providers(), props.message.providerID, props.message.modelID))
|
|
1462
|
+
|
|
1463
|
+
const final = createMemo(() => {
|
|
1464
|
+
return props.message.finish && !["tool-calls", "unknown"].includes(props.message.finish)
|
|
1465
|
+
})
|
|
1466
|
+
|
|
1467
|
+
const duration = createMemo(() => {
|
|
1468
|
+
if (!final()) return 0
|
|
1469
|
+
if (!props.message.time.completed) return 0
|
|
1470
|
+
const user = messages().find((x) => x.role === "user" && x.id === props.message.parentID)
|
|
1471
|
+
if (!user || !user.time) return 0
|
|
1472
|
+
return props.message.time.completed - user.time.created
|
|
1473
|
+
})
|
|
1474
|
+
|
|
1475
|
+
const childShortcut = useCommandShortcut("session.child.first")
|
|
1476
|
+
const backgroundShortcut = useCommandShortcut("session.background")
|
|
1477
|
+
|
|
1478
|
+
return (
|
|
1479
|
+
<>
|
|
1480
|
+
<For each={props.parts}>
|
|
1481
|
+
{(part, index) => {
|
|
1482
|
+
const component = createMemo(() => PART_MAPPING[part.type as keyof typeof PART_MAPPING])
|
|
1483
|
+
return (
|
|
1484
|
+
<Show when={component()}>
|
|
1485
|
+
<Dynamic
|
|
1486
|
+
last={index() === props.parts.length - 1}
|
|
1487
|
+
component={component()}
|
|
1488
|
+
part={part as any}
|
|
1489
|
+
message={props.message}
|
|
1490
|
+
/>
|
|
1491
|
+
</Show>
|
|
1492
|
+
)
|
|
1493
|
+
}}
|
|
1494
|
+
</For>
|
|
1495
|
+
<Show when={props.parts.some((x) => x.type === "tool" && x.tool === "task")}>
|
|
1496
|
+
<box paddingTop={1} paddingLeft={3}>
|
|
1497
|
+
<text fg={theme.text}>
|
|
1498
|
+
{childShortcut()}
|
|
1499
|
+
<span style={{ fg: theme.textMuted }}> view subagents</span>
|
|
1500
|
+
<Show
|
|
1501
|
+
when={
|
|
1502
|
+
sync.data.capabilities.experimentalBackgroundSubagents &&
|
|
1503
|
+
props.parts.some(
|
|
1504
|
+
(x) =>
|
|
1505
|
+
x.type === "tool" &&
|
|
1506
|
+
x.tool === "task" &&
|
|
1507
|
+
x.state.status === "running" &&
|
|
1508
|
+
x.state.metadata?.background !== true,
|
|
1509
|
+
)
|
|
1510
|
+
}
|
|
1511
|
+
>
|
|
1512
|
+
<span style={{ fg: theme.textMuted }}> · </span>
|
|
1513
|
+
{backgroundShortcut()}
|
|
1514
|
+
<span style={{ fg: theme.textMuted }}> background</span>
|
|
1515
|
+
</Show>
|
|
1516
|
+
</text>
|
|
1517
|
+
</box>
|
|
1518
|
+
</Show>
|
|
1519
|
+
<Show when={props.message.error && props.message.error.name !== "MessageAbortedError"}>
|
|
1520
|
+
<box
|
|
1521
|
+
ref={(el: BoxRenderable) => alwaysSeparate.add(el)}
|
|
1522
|
+
border={["left"]}
|
|
1523
|
+
paddingTop={1}
|
|
1524
|
+
paddingBottom={1}
|
|
1525
|
+
paddingLeft={2}
|
|
1526
|
+
marginTop={1}
|
|
1527
|
+
backgroundColor={theme.backgroundPanel}
|
|
1528
|
+
customBorderChars={SplitBorder.customBorderChars}
|
|
1529
|
+
borderColor={theme.error}
|
|
1530
|
+
>
|
|
1531
|
+
<text fg={theme.textMuted}>{errorMessage(props.message.error)}</text>
|
|
1532
|
+
</box>
|
|
1533
|
+
</Show>
|
|
1534
|
+
<Switch>
|
|
1535
|
+
<Match when={props.last || final() || props.message.error?.name === "MessageAbortedError"}>
|
|
1536
|
+
<box ref={(el: BoxRenderable) => alwaysSeparate.add(el)} paddingLeft={3}>
|
|
1537
|
+
<text marginTop={1}>
|
|
1538
|
+
<span
|
|
1539
|
+
style={{
|
|
1540
|
+
fg:
|
|
1541
|
+
props.message.error?.name === "MessageAbortedError"
|
|
1542
|
+
? theme.textMuted
|
|
1543
|
+
: local.agent.color(props.message.agent),
|
|
1544
|
+
}}
|
|
1545
|
+
>
|
|
1546
|
+
▣{" "}
|
|
1547
|
+
</span>{" "}
|
|
1548
|
+
<span style={{ fg: theme.text }}>{Locale.titlecase(props.message.mode)}</span>
|
|
1549
|
+
<span style={{ fg: theme.textMuted }}> · {model()}</span>
|
|
1550
|
+
<Show when={duration()}>
|
|
1551
|
+
<span style={{ fg: theme.textMuted }}> · {Locale.duration(duration())}</span>
|
|
1552
|
+
</Show>
|
|
1553
|
+
<Show when={props.message.error?.name === "MessageAbortedError"}>
|
|
1554
|
+
<span style={{ fg: theme.textMuted }}> · interrupted</span>
|
|
1555
|
+
</Show>
|
|
1556
|
+
</text>
|
|
1557
|
+
</box>
|
|
1558
|
+
</Match>
|
|
1559
|
+
</Switch>
|
|
1560
|
+
</>
|
|
1561
|
+
)
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
const PART_MAPPING = {
|
|
1565
|
+
text: TextPart,
|
|
1566
|
+
tool: ToolPart,
|
|
1567
|
+
reasoning: ReasoningPart,
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
const INLINE_TOOL_ICON_WIDTH = 2
|
|
1571
|
+
|
|
1572
|
+
function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: AssistantMessage }) {
|
|
1573
|
+
const { theme } = useTheme()
|
|
1574
|
+
const ctx = use()
|
|
1575
|
+
// Collapsed by default in hide mode: a single line throughout, so the
|
|
1576
|
+
// layout never shifts. Click to open the full markdown block, click to close.
|
|
1577
|
+
const [expanded, setExpanded] = createSignal(false)
|
|
1578
|
+
|
|
1579
|
+
const content = createMemo(() => {
|
|
1580
|
+
// OpenRouter encrypts some reasoning blocks; drop the placeholder.
|
|
1581
|
+
return props.part.text.replace("[REDACTED]", "").trim()
|
|
1582
|
+
})
|
|
1583
|
+
// Reasoning is finalized when the server sets `time.end` (see processor.ts).
|
|
1584
|
+
// Flips independently of the parent message completing.
|
|
1585
|
+
const isDone = createMemo(() => props.part.time.end !== undefined)
|
|
1586
|
+
const inMinimal = createMemo(() => ctx.thinkingMode() === "hide")
|
|
1587
|
+
const duration = createMemo(() => {
|
|
1588
|
+
const end = props.part.time.end
|
|
1589
|
+
return end === undefined ? 0 : Math.max(0, end - props.part.time.start)
|
|
1590
|
+
})
|
|
1591
|
+
const summary = createMemo(() => reasoningSummary(content()))
|
|
1592
|
+
const syntax = createSyntaxStyleMemo(() => generateSubtleSyntax(theme))
|
|
1593
|
+
|
|
1594
|
+
const toggle = () => {
|
|
1595
|
+
if (!inMinimal()) return
|
|
1596
|
+
setExpanded((prev) => !prev)
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
return (
|
|
1600
|
+
<Show when={content()}>
|
|
1601
|
+
<box
|
|
1602
|
+
ref={(el: BoxRenderable) => alwaysSeparate.add(el)}
|
|
1603
|
+
paddingLeft={3}
|
|
1604
|
+
marginTop={1}
|
|
1605
|
+
flexDirection="column"
|
|
1606
|
+
flexShrink={0}
|
|
1607
|
+
>
|
|
1608
|
+
<box onMouseUp={toggle}>
|
|
1609
|
+
<ReasoningHeader
|
|
1610
|
+
toggleable={inMinimal()}
|
|
1611
|
+
open={!inMinimal() || expanded()}
|
|
1612
|
+
done={isDone()}
|
|
1613
|
+
title={summary().title}
|
|
1614
|
+
duration={isDone() ? Locale.duration(duration()) : undefined}
|
|
1615
|
+
/>
|
|
1616
|
+
</box>
|
|
1617
|
+
<Show when={(!inMinimal() || expanded()) && summary().body}>
|
|
1618
|
+
<box paddingLeft={inMinimal() ? 2 : 0} marginTop={1}>
|
|
1619
|
+
<code
|
|
1620
|
+
filetype="markdown"
|
|
1621
|
+
drawUnstyledText={false}
|
|
1622
|
+
streaming={true}
|
|
1623
|
+
syntaxStyle={syntax()}
|
|
1624
|
+
content={summary().body}
|
|
1625
|
+
conceal={ctx.conceal()}
|
|
1626
|
+
fg={theme.textMuted}
|
|
1627
|
+
/>
|
|
1628
|
+
</box>
|
|
1629
|
+
</Show>
|
|
1630
|
+
</box>
|
|
1631
|
+
</Show>
|
|
1632
|
+
)
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
function ReasoningHeader(props: {
|
|
1636
|
+
toggleable: boolean
|
|
1637
|
+
open: boolean
|
|
1638
|
+
done: boolean
|
|
1639
|
+
title: string | null
|
|
1640
|
+
duration?: string
|
|
1641
|
+
}) {
|
|
1642
|
+
const { theme } = useTheme()
|
|
1643
|
+
const fg = () =>
|
|
1644
|
+
props.open
|
|
1645
|
+
? RGBA.fromValues(theme.warning.r, theme.warning.g, theme.warning.b, theme.thinkingOpacity)
|
|
1646
|
+
: theme.warning
|
|
1647
|
+
|
|
1648
|
+
return (
|
|
1649
|
+
<Switch>
|
|
1650
|
+
<Match when={!props.done}>
|
|
1651
|
+
<box flexDirection="row">
|
|
1652
|
+
<Spinner color={fg()}>{props.title ? "Thinking: " + props.title : "Thinking"}</Spinner>
|
|
1653
|
+
</box>
|
|
1654
|
+
</Match>
|
|
1655
|
+
<Match when={true}>
|
|
1656
|
+
<text fg={fg()} wrapMode="none">
|
|
1657
|
+
<Show when={props.toggleable}>
|
|
1658
|
+
<span>{props.open ? "- " : "+ "}</span>
|
|
1659
|
+
</Show>
|
|
1660
|
+
<span>Thought</span>
|
|
1661
|
+
<Show when={props.title || props.duration}>
|
|
1662
|
+
<span>: </span>
|
|
1663
|
+
</Show>
|
|
1664
|
+
<Show when={props.title}>
|
|
1665
|
+
<span>{props.title}</span>
|
|
1666
|
+
</Show>
|
|
1667
|
+
<Show when={props.duration}>
|
|
1668
|
+
<span>
|
|
1669
|
+
{props.title ? " · " : ""}
|
|
1670
|
+
{props.duration}
|
|
1671
|
+
</span>
|
|
1672
|
+
</Show>
|
|
1673
|
+
</text>
|
|
1674
|
+
</Match>
|
|
1675
|
+
</Switch>
|
|
1676
|
+
)
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
function TextPart(props: { last: boolean; part: TextPart; message: AssistantMessage }) {
|
|
1680
|
+
const ctx = use()
|
|
1681
|
+
const { theme, syntax } = useTheme()
|
|
1682
|
+
return (
|
|
1683
|
+
<Show when={props.part.text.trim()}>
|
|
1684
|
+
<box ref={(el: BoxRenderable) => alwaysSeparate.add(el)} paddingLeft={3} marginTop={1} flexShrink={0}>
|
|
1685
|
+
<markdown
|
|
1686
|
+
syntaxStyle={syntax()}
|
|
1687
|
+
streaming={true}
|
|
1688
|
+
internalBlockMode="top-level"
|
|
1689
|
+
content={props.part.text.trim()}
|
|
1690
|
+
tableOptions={{ style: "grid" }}
|
|
1691
|
+
conceal={ctx.conceal()}
|
|
1692
|
+
fg={theme.markdownText}
|
|
1693
|
+
bg={theme.background}
|
|
1694
|
+
/>
|
|
1695
|
+
</box>
|
|
1696
|
+
</Show>
|
|
1697
|
+
)
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
// Pending messages moved to individual tool pending functions
|
|
1701
|
+
|
|
1702
|
+
function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMessage }) {
|
|
1703
|
+
const ctx = use()
|
|
1704
|
+
const display = createMemo(() => toolDisplay(props.part.tool))
|
|
1705
|
+
|
|
1706
|
+
// Hide tool if showDetails is false and tool completed successfully
|
|
1707
|
+
const shouldHide = createMemo(() => {
|
|
1708
|
+
if (ctx.showDetails()) return false
|
|
1709
|
+
if (props.part.state.status !== "completed") return false
|
|
1710
|
+
return true
|
|
1711
|
+
})
|
|
1712
|
+
|
|
1713
|
+
const toolprops = {
|
|
1714
|
+
get metadata() {
|
|
1715
|
+
return props.part.state.status === "pending" ? {} : (props.part.state.metadata ?? {})
|
|
1716
|
+
},
|
|
1717
|
+
get input() {
|
|
1718
|
+
return props.part.state.input ?? {}
|
|
1719
|
+
},
|
|
1720
|
+
get output() {
|
|
1721
|
+
return props.part.state.status === "completed" ? props.part.state.output : undefined
|
|
1722
|
+
},
|
|
1723
|
+
get tool() {
|
|
1724
|
+
return props.part.tool
|
|
1725
|
+
},
|
|
1726
|
+
get part() {
|
|
1727
|
+
return props.part
|
|
1728
|
+
},
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1731
|
+
return (
|
|
1732
|
+
<Show when={!shouldHide()}>
|
|
1733
|
+
<Switch>
|
|
1734
|
+
<Match when={display() === "bash"}>
|
|
1735
|
+
<Shell {...toolprops} />
|
|
1736
|
+
</Match>
|
|
1737
|
+
<Match when={display() === "glob"}>
|
|
1738
|
+
<Glob {...toolprops} />
|
|
1739
|
+
</Match>
|
|
1740
|
+
<Match when={display() === "read"}>
|
|
1741
|
+
<Read {...toolprops} />
|
|
1742
|
+
</Match>
|
|
1743
|
+
<Match when={display() === "grep"}>
|
|
1744
|
+
<Grep {...toolprops} />
|
|
1745
|
+
</Match>
|
|
1746
|
+
<Match when={display() === "webfetch"}>
|
|
1747
|
+
<WebFetch {...toolprops} />
|
|
1748
|
+
</Match>
|
|
1749
|
+
<Match when={display() === "websearch"}>
|
|
1750
|
+
<WebSearch {...toolprops} />
|
|
1751
|
+
</Match>
|
|
1752
|
+
<Match when={display() === "write"}>
|
|
1753
|
+
<Write {...toolprops} />
|
|
1754
|
+
</Match>
|
|
1755
|
+
<Match when={display() === "edit"}>
|
|
1756
|
+
<Edit {...toolprops} />
|
|
1757
|
+
</Match>
|
|
1758
|
+
<Match when={display() === "task"}>
|
|
1759
|
+
<Task {...toolprops} />
|
|
1760
|
+
</Match>
|
|
1761
|
+
<Match when={display() === "execute"}>
|
|
1762
|
+
<Execute {...toolprops} />
|
|
1763
|
+
</Match>
|
|
1764
|
+
<Match when={display() === "apply_patch"}>
|
|
1765
|
+
<ApplyPatch {...toolprops} />
|
|
1766
|
+
</Match>
|
|
1767
|
+
<Match when={display() === "todowrite"}>
|
|
1768
|
+
<TodoWrite {...toolprops} />
|
|
1769
|
+
</Match>
|
|
1770
|
+
<Match when={display() === "question"}>
|
|
1771
|
+
<Question {...toolprops} />
|
|
1772
|
+
</Match>
|
|
1773
|
+
<Match when={display() === "skill"}>
|
|
1774
|
+
<Skill {...toolprops} />
|
|
1775
|
+
</Match>
|
|
1776
|
+
<Match when={true}>
|
|
1777
|
+
<GenericTool {...toolprops} />
|
|
1778
|
+
</Match>
|
|
1779
|
+
</Switch>
|
|
1780
|
+
</Show>
|
|
1781
|
+
)
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
type ToolProps = {
|
|
1785
|
+
input: Record<string, unknown>
|
|
1786
|
+
metadata: Record<string, unknown>
|
|
1787
|
+
tool: string
|
|
1788
|
+
output?: string
|
|
1789
|
+
part: ToolPart
|
|
1790
|
+
}
|
|
1791
|
+
function GenericTool(props: ToolProps) {
|
|
1792
|
+
const { theme } = useTheme()
|
|
1793
|
+
const ctx = use()
|
|
1794
|
+
const output = createMemo(() => props.output?.trim() ?? "")
|
|
1795
|
+
const [expanded, setExpanded] = createSignal(false)
|
|
1796
|
+
const maxLines = 3
|
|
1797
|
+
const maxChars = createMemo(() => maxLines * Math.max(20, ctx.width - 6))
|
|
1798
|
+
const collapsed = createMemo(() => collapseToolOutput(output(), maxLines, maxChars()))
|
|
1799
|
+
const limited = createMemo(() => {
|
|
1800
|
+
if (expanded() || !collapsed().overflow) return output()
|
|
1801
|
+
return collapsed().output
|
|
1802
|
+
})
|
|
1803
|
+
|
|
1804
|
+
return (
|
|
1805
|
+
<Show
|
|
1806
|
+
when={props.output && ctx.showGenericToolOutput()}
|
|
1807
|
+
fallback={
|
|
1808
|
+
<InlineTool icon="⚙" pending="Writing command..." complete={true} part={props.part}>
|
|
1809
|
+
{props.tool} {input(props.input)}
|
|
1810
|
+
</InlineTool>
|
|
1811
|
+
}
|
|
1812
|
+
>
|
|
1813
|
+
<BlockTool
|
|
1814
|
+
title={`# ${props.tool} ${input(props.input)}`}
|
|
1815
|
+
part={props.part}
|
|
1816
|
+
onClick={collapsed().overflow ? () => setExpanded((prev) => !prev) : undefined}
|
|
1817
|
+
>
|
|
1818
|
+
<box gap={1}>
|
|
1819
|
+
<text fg={theme.text}>{limited()}</text>
|
|
1820
|
+
<Show when={collapsed().overflow}>
|
|
1821
|
+
<text fg={theme.textMuted}>{expanded() ? "Click to collapse" : "Click to expand"}</text>
|
|
1822
|
+
</Show>
|
|
1823
|
+
</box>
|
|
1824
|
+
</BlockTool>
|
|
1825
|
+
</Show>
|
|
1826
|
+
)
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
function InlineTool(props: {
|
|
1830
|
+
icon: string
|
|
1831
|
+
iconColor?: RGBA
|
|
1832
|
+
color?: RGBA
|
|
1833
|
+
complete: unknown
|
|
1834
|
+
pending: string
|
|
1835
|
+
failure?: string
|
|
1836
|
+
spinner?: boolean
|
|
1837
|
+
separate?: boolean
|
|
1838
|
+
children: JSX.Element
|
|
1839
|
+
part: ToolPart
|
|
1840
|
+
onClick?: () => void
|
|
1841
|
+
}) {
|
|
1842
|
+
const { theme } = useTheme()
|
|
1843
|
+
const ctx = use()
|
|
1844
|
+
const sync = useSync()
|
|
1845
|
+
const renderer = useRenderer()
|
|
1846
|
+
const [hover, setHover] = createSignal(false)
|
|
1847
|
+
const [errorExpanded, setErrorExpanded] = createSignal(false)
|
|
1848
|
+
|
|
1849
|
+
const permission = createMemo(() => {
|
|
1850
|
+
const callID = sync.data.permission[ctx.sessionID]?.at(0)?.tool?.callID
|
|
1851
|
+
if (!callID) return false
|
|
1852
|
+
return callID === props.part.callID
|
|
1853
|
+
})
|
|
1854
|
+
|
|
1855
|
+
const error = createMemo(() => (props.part.state.status === "error" ? props.part.state.error : undefined))
|
|
1856
|
+
|
|
1857
|
+
const denied = createMemo(
|
|
1858
|
+
() =>
|
|
1859
|
+
error()?.includes("QuestionRejectedError") ||
|
|
1860
|
+
error()?.includes("rejected permission") ||
|
|
1861
|
+
error()?.includes("specified a rule") ||
|
|
1862
|
+
error()?.includes("user dismissed"),
|
|
1863
|
+
)
|
|
1864
|
+
|
|
1865
|
+
const failed = createMemo(() => Boolean(error() && !denied()))
|
|
1866
|
+
const clickable = createMemo(() => Boolean(props.onClick || failed()))
|
|
1867
|
+
const fg = createMemo(() => {
|
|
1868
|
+
if (props.color) return props.color
|
|
1869
|
+
if (permission()) return theme.warning
|
|
1870
|
+
if (failed()) return theme.error
|
|
1871
|
+
if (hover() && props.onClick) return theme.text
|
|
1872
|
+
if (props.complete) return theme.textMuted
|
|
1873
|
+
return theme.text
|
|
1874
|
+
})
|
|
1875
|
+
|
|
1876
|
+
return (
|
|
1877
|
+
<InlineToolRow
|
|
1878
|
+
icon={props.icon}
|
|
1879
|
+
iconColor={props.iconColor}
|
|
1880
|
+
color={fg()}
|
|
1881
|
+
errorColor={theme.error}
|
|
1882
|
+
failed={failed()}
|
|
1883
|
+
denied={Boolean(denied())}
|
|
1884
|
+
error={error()}
|
|
1885
|
+
errorExpanded={errorExpanded()}
|
|
1886
|
+
complete={props.complete}
|
|
1887
|
+
pending={props.pending}
|
|
1888
|
+
failure={props.failure}
|
|
1889
|
+
spinner={props.spinner}
|
|
1890
|
+
separate={props.separate}
|
|
1891
|
+
onMouseOver={() => clickable() && setHover(true)}
|
|
1892
|
+
onMouseOut={() => setHover(false)}
|
|
1893
|
+
onMouseUp={() => {
|
|
1894
|
+
if (renderer.getSelection()?.getSelectedText()) return
|
|
1895
|
+
if (failed()) {
|
|
1896
|
+
setErrorExpanded((value) => !value)
|
|
1897
|
+
return
|
|
1898
|
+
}
|
|
1899
|
+
props.onClick?.()
|
|
1900
|
+
}}
|
|
1901
|
+
>
|
|
1902
|
+
{props.children}
|
|
1903
|
+
</InlineToolRow>
|
|
1904
|
+
)
|
|
1905
|
+
}
|
|
1906
|
+
|
|
1907
|
+
export function InlineToolRow(props: {
|
|
1908
|
+
icon: string
|
|
1909
|
+
iconColor?: RGBA
|
|
1910
|
+
color?: RGBA
|
|
1911
|
+
errorColor?: RGBA
|
|
1912
|
+
failed?: boolean
|
|
1913
|
+
denied?: boolean
|
|
1914
|
+
error?: string
|
|
1915
|
+
errorExpanded?: boolean
|
|
1916
|
+
complete: unknown
|
|
1917
|
+
pending: string
|
|
1918
|
+
failure?: string
|
|
1919
|
+
spinner?: boolean
|
|
1920
|
+
separate?: boolean
|
|
1921
|
+
children: JSX.Element
|
|
1922
|
+
onMouseOver?: () => void
|
|
1923
|
+
onMouseOut?: () => void
|
|
1924
|
+
onMouseUp?: () => void
|
|
1925
|
+
}) {
|
|
1926
|
+
return (
|
|
1927
|
+
<box
|
|
1928
|
+
paddingLeft={3}
|
|
1929
|
+
onMouseOver={props.onMouseOver}
|
|
1930
|
+
onMouseOut={props.onMouseOut}
|
|
1931
|
+
onMouseUp={props.onMouseUp}
|
|
1932
|
+
ref={(el: BoxRenderable) => {
|
|
1933
|
+
if (props.separate) alwaysSeparate.add(el)
|
|
1934
|
+
setPreLayoutSiblingMargin(el, (previous) => {
|
|
1935
|
+
return props.separate ||
|
|
1936
|
+
(previous instanceof BoxRenderable && (previous.height > 1 || alwaysSeparate.has(previous)))
|
|
1937
|
+
? 1
|
|
1938
|
+
: 0
|
|
1939
|
+
})
|
|
1940
|
+
}}
|
|
1941
|
+
>
|
|
1942
|
+
<Switch>
|
|
1943
|
+
<Match when={props.spinner}>
|
|
1944
|
+
<Spinner color={props.color} children={props.children} />
|
|
1945
|
+
</Match>
|
|
1946
|
+
<Match when={true}>
|
|
1947
|
+
<Show
|
|
1948
|
+
fallback={
|
|
1949
|
+
<text
|
|
1950
|
+
paddingLeft={3}
|
|
1951
|
+
fg={props.color}
|
|
1952
|
+
attributes={props.denied ? TextAttributes.STRIKETHROUGH : undefined}
|
|
1953
|
+
>
|
|
1954
|
+
~ {props.pending}
|
|
1955
|
+
</text>
|
|
1956
|
+
}
|
|
1957
|
+
when={props.complete || props.failed}
|
|
1958
|
+
>
|
|
1959
|
+
<box flexDirection="row">
|
|
1960
|
+
<text
|
|
1961
|
+
width={INLINE_TOOL_ICON_WIDTH}
|
|
1962
|
+
fg={props.failed ? props.errorColor : (props.iconColor ?? props.color)}
|
|
1963
|
+
attributes={props.denied ? TextAttributes.STRIKETHROUGH : undefined}
|
|
1964
|
+
>
|
|
1965
|
+
{props.icon}
|
|
1966
|
+
</text>
|
|
1967
|
+
<text
|
|
1968
|
+
flexGrow={1}
|
|
1969
|
+
fg={props.failed ? props.errorColor : props.color}
|
|
1970
|
+
attributes={props.denied ? TextAttributes.STRIKETHROUGH : undefined}
|
|
1971
|
+
>
|
|
1972
|
+
{props.failed && !props.complete ? (props.failure ?? props.children) : props.children}
|
|
1973
|
+
</text>
|
|
1974
|
+
</box>
|
|
1975
|
+
</Show>
|
|
1976
|
+
</Match>
|
|
1977
|
+
</Switch>
|
|
1978
|
+
<Show when={props.failed && props.errorExpanded}>
|
|
1979
|
+
<box paddingLeft={INLINE_TOOL_ICON_WIDTH}>
|
|
1980
|
+
<text fg={props.errorColor}>{props.error}</text>
|
|
1981
|
+
</box>
|
|
1982
|
+
</Show>
|
|
1983
|
+
</box>
|
|
1984
|
+
)
|
|
1985
|
+
}
|
|
1986
|
+
|
|
1987
|
+
function BlockTool(props: {
|
|
1988
|
+
title?: string
|
|
1989
|
+
children: JSX.Element
|
|
1990
|
+
onClick?: () => void
|
|
1991
|
+
part?: ToolPart
|
|
1992
|
+
spinner?: boolean
|
|
1993
|
+
}) {
|
|
1994
|
+
const { theme } = useTheme()
|
|
1995
|
+
const renderer = useRenderer()
|
|
1996
|
+
const [hover, setHover] = createSignal(false)
|
|
1997
|
+
const error = createMemo(() => (props.part?.state.status === "error" ? props.part.state.error : undefined))
|
|
1998
|
+
return (
|
|
1999
|
+
<box
|
|
2000
|
+
ref={(el: BoxRenderable) => alwaysSeparate.add(el)}
|
|
2001
|
+
border={["left"]}
|
|
2002
|
+
paddingTop={1}
|
|
2003
|
+
paddingBottom={1}
|
|
2004
|
+
paddingLeft={2}
|
|
2005
|
+
marginTop={1}
|
|
2006
|
+
gap={1}
|
|
2007
|
+
backgroundColor={hover() ? theme.backgroundMenu : theme.backgroundPanel}
|
|
2008
|
+
customBorderChars={SplitBorder.customBorderChars}
|
|
2009
|
+
borderColor={theme.background}
|
|
2010
|
+
onMouseOver={() => props.onClick && setHover(true)}
|
|
2011
|
+
onMouseOut={() => setHover(false)}
|
|
2012
|
+
onMouseUp={() => {
|
|
2013
|
+
if (renderer.getSelection()?.getSelectedText()) return
|
|
2014
|
+
props.onClick?.()
|
|
2015
|
+
}}
|
|
2016
|
+
>
|
|
2017
|
+
<Show when={props.title}>
|
|
2018
|
+
{(title) => (
|
|
2019
|
+
<Show
|
|
2020
|
+
when={props.spinner}
|
|
2021
|
+
fallback={
|
|
2022
|
+
<text paddingLeft={3} fg={theme.textMuted}>
|
|
2023
|
+
{title()}
|
|
2024
|
+
</text>
|
|
2025
|
+
}
|
|
2026
|
+
>
|
|
2027
|
+
<Spinner color={theme.textMuted}>{title().replace(/^# /, "")}</Spinner>
|
|
2028
|
+
</Show>
|
|
2029
|
+
)}
|
|
2030
|
+
</Show>
|
|
2031
|
+
{props.children}
|
|
2032
|
+
<Show when={error()}>
|
|
2033
|
+
<text fg={theme.error}>{error()}</text>
|
|
2034
|
+
</Show>
|
|
2035
|
+
</box>
|
|
2036
|
+
)
|
|
2037
|
+
}
|
|
2038
|
+
|
|
2039
|
+
function Shell(props: ToolProps) {
|
|
2040
|
+
const { theme } = useTheme()
|
|
2041
|
+
const pathFormatter = usePathFormatter()
|
|
2042
|
+
const ctx = use()
|
|
2043
|
+
const isRunning = createMemo(() => props.part.state.status === "running")
|
|
2044
|
+
const output = createMemo(() => stripAnsi(stringValue(props.metadata.output)?.trim() ?? ""))
|
|
2045
|
+
const [expanded, setExpanded] = createSignal(false)
|
|
2046
|
+
const maxLines = 10
|
|
2047
|
+
const maxChars = createMemo(() => maxLines * Math.max(20, ctx.width - 6))
|
|
2048
|
+
const collapsed = createMemo(() => collapseToolOutput(output(), maxLines, maxChars()))
|
|
2049
|
+
const limited = createMemo(() => {
|
|
2050
|
+
if (expanded() || !collapsed().overflow) return output()
|
|
2051
|
+
return collapsed().output
|
|
2052
|
+
})
|
|
2053
|
+
|
|
2054
|
+
const workdirDisplay = createMemo(() => {
|
|
2055
|
+
const workdir = stringValue(props.input.workdir)
|
|
2056
|
+
if (!workdir || workdir === ".") return undefined
|
|
2057
|
+
const formatted = pathFormatter.format(workdir)
|
|
2058
|
+
if (formatted === ".") return undefined
|
|
2059
|
+
return formatted
|
|
2060
|
+
})
|
|
2061
|
+
|
|
2062
|
+
const title = createMemo(() => {
|
|
2063
|
+
const wd = workdirDisplay()
|
|
2064
|
+
if (!wd) return
|
|
2065
|
+
return `# Running in ${wd}`
|
|
2066
|
+
})
|
|
2067
|
+
|
|
2068
|
+
return (
|
|
2069
|
+
<Switch>
|
|
2070
|
+
<Match when={stringValue(props.metadata.output) !== undefined}>
|
|
2071
|
+
<BlockTool
|
|
2072
|
+
title={title()}
|
|
2073
|
+
part={props.part}
|
|
2074
|
+
onClick={collapsed().overflow ? () => setExpanded((prev) => !prev) : undefined}
|
|
2075
|
+
>
|
|
2076
|
+
<box gap={1}>
|
|
2077
|
+
<Show when={isRunning()} fallback={<text fg={theme.text}>$ {stringValue(props.input.command)}</text>}>
|
|
2078
|
+
<Spinner color={theme.text}>{stringValue(props.input.command)}</Spinner>
|
|
2079
|
+
</Show>
|
|
2080
|
+
<Show when={output()}>
|
|
2081
|
+
<text fg={theme.text}>{limited()}</text>
|
|
2082
|
+
</Show>
|
|
2083
|
+
<Show when={collapsed().overflow}>
|
|
2084
|
+
<text fg={theme.textMuted}>{expanded() ? "Click to collapse" : "Click to expand"}</text>
|
|
2085
|
+
</Show>
|
|
2086
|
+
</box>
|
|
2087
|
+
</BlockTool>
|
|
2088
|
+
</Match>
|
|
2089
|
+
<Match when={true}>
|
|
2090
|
+
<InlineTool icon="$" pending="Writing command..." complete={stringValue(props.input.command)} part={props.part}>
|
|
2091
|
+
{stringValue(props.input.command)}
|
|
2092
|
+
</InlineTool>
|
|
2093
|
+
</Match>
|
|
2094
|
+
</Switch>
|
|
2095
|
+
)
|
|
2096
|
+
}
|
|
2097
|
+
|
|
2098
|
+
function Write(props: ToolProps) {
|
|
2099
|
+
const { theme, syntax } = useTheme()
|
|
2100
|
+
const pathFormatter = usePathFormatter()
|
|
2101
|
+
const code = createMemo(() => {
|
|
2102
|
+
return stringValue(props.input.content) ?? ""
|
|
2103
|
+
})
|
|
2104
|
+
|
|
2105
|
+
return (
|
|
2106
|
+
<Switch>
|
|
2107
|
+
<Match when={props.metadata.diagnostics !== undefined}>
|
|
2108
|
+
<BlockTool title={"# Wrote " + pathFormatter.format(stringValue(props.input.filePath))} part={props.part}>
|
|
2109
|
+
<line_number fg={theme.textMuted} minWidth={3} paddingRight={1}>
|
|
2110
|
+
<code
|
|
2111
|
+
conceal={false}
|
|
2112
|
+
fg={theme.text}
|
|
2113
|
+
filetype={filetype(stringValue(props.input.filePath))}
|
|
2114
|
+
syntaxStyle={syntax()}
|
|
2115
|
+
content={code()}
|
|
2116
|
+
/>
|
|
2117
|
+
</line_number>
|
|
2118
|
+
<Diagnostics diagnostics={props.metadata.diagnostics} filePath={stringValue(props.input.filePath) ?? ""} />
|
|
2119
|
+
</BlockTool>
|
|
2120
|
+
</Match>
|
|
2121
|
+
<Match when={true}>
|
|
2122
|
+
<InlineTool
|
|
2123
|
+
icon="←"
|
|
2124
|
+
pending="Preparing write..."
|
|
2125
|
+
complete={stringValue(props.input.filePath)}
|
|
2126
|
+
part={props.part}
|
|
2127
|
+
>
|
|
2128
|
+
Write {pathFormatter.format(stringValue(props.input.filePath))}
|
|
2129
|
+
</InlineTool>
|
|
2130
|
+
</Match>
|
|
2131
|
+
</Switch>
|
|
2132
|
+
)
|
|
2133
|
+
}
|
|
2134
|
+
|
|
2135
|
+
function Glob(props: ToolProps) {
|
|
2136
|
+
const pathFormatter = usePathFormatter()
|
|
2137
|
+
return (
|
|
2138
|
+
<InlineTool icon="✱" pending="Finding files..." complete={stringValue(props.input.pattern)} part={props.part}>
|
|
2139
|
+
Glob "{stringValue(props.input.pattern)}"{" "}
|
|
2140
|
+
<Show when={stringValue(props.input.path)}>in {pathFormatter.format(stringValue(props.input.path))} </Show>
|
|
2141
|
+
<Show when={numberValue(props.metadata.count)}>
|
|
2142
|
+
({numberValue(props.metadata.count)} {numberValue(props.metadata.count) === 1 ? "match" : "matches"})
|
|
2143
|
+
</Show>
|
|
2144
|
+
</InlineTool>
|
|
2145
|
+
)
|
|
2146
|
+
}
|
|
2147
|
+
|
|
2148
|
+
function Read(props: ToolProps) {
|
|
2149
|
+
const { theme } = useTheme()
|
|
2150
|
+
const pathFormatter = usePathFormatter()
|
|
2151
|
+
const isRunning = createMemo(() => props.part.state.status === "running")
|
|
2152
|
+
const loaded = createMemo(() => {
|
|
2153
|
+
if (props.part.state.status !== "completed") return []
|
|
2154
|
+
if (props.part.state.time.compacted) return []
|
|
2155
|
+
const value = props.metadata.loaded
|
|
2156
|
+
if (!value || !Array.isArray(value)) return []
|
|
2157
|
+
return value.filter((p): p is string => typeof p === "string")
|
|
2158
|
+
})
|
|
2159
|
+
return (
|
|
2160
|
+
<>
|
|
2161
|
+
<InlineTool
|
|
2162
|
+
icon="→"
|
|
2163
|
+
pending="Reading file..."
|
|
2164
|
+
complete={stringValue(props.input.filePath)}
|
|
2165
|
+
spinner={isRunning()}
|
|
2166
|
+
part={props.part}
|
|
2167
|
+
>
|
|
2168
|
+
Read {pathFormatter.format(stringValue(props.input.filePath))} {input(props.input, ["filePath"])}
|
|
2169
|
+
</InlineTool>
|
|
2170
|
+
<For each={loaded()}>
|
|
2171
|
+
{(filepath) => (
|
|
2172
|
+
<box paddingLeft={3}>
|
|
2173
|
+
<text paddingLeft={3} fg={theme.textMuted}>
|
|
2174
|
+
↳ Loaded {pathFormatter.format(filepath)}
|
|
2175
|
+
</text>
|
|
2176
|
+
</box>
|
|
2177
|
+
)}
|
|
2178
|
+
</For>
|
|
2179
|
+
</>
|
|
2180
|
+
)
|
|
2181
|
+
}
|
|
2182
|
+
|
|
2183
|
+
function Grep(props: ToolProps) {
|
|
2184
|
+
const pathFormatter = usePathFormatter()
|
|
2185
|
+
return (
|
|
2186
|
+
<InlineTool icon="✱" pending="Searching content..." complete={stringValue(props.input.pattern)} part={props.part}>
|
|
2187
|
+
Grep "{stringValue(props.input.pattern)}"{" "}
|
|
2188
|
+
<Show when={stringValue(props.input.path)}>in {pathFormatter.format(stringValue(props.input.path))} </Show>
|
|
2189
|
+
<Show when={numberValue(props.metadata.matches)}>
|
|
2190
|
+
({numberValue(props.metadata.matches)} {numberValue(props.metadata.matches) === 1 ? "match" : "matches"})
|
|
2191
|
+
</Show>
|
|
2192
|
+
</InlineTool>
|
|
2193
|
+
)
|
|
2194
|
+
}
|
|
2195
|
+
|
|
2196
|
+
function WebFetch(props: ToolProps) {
|
|
2197
|
+
return (
|
|
2198
|
+
<InlineTool icon="%" pending="Fetching from the web..." complete={stringValue(props.input.url)} part={props.part}>
|
|
2199
|
+
WebFetch {stringValue(props.input.url)}
|
|
2200
|
+
</InlineTool>
|
|
2201
|
+
)
|
|
2202
|
+
}
|
|
2203
|
+
|
|
2204
|
+
function WebSearch(props: ToolProps) {
|
|
2205
|
+
return (
|
|
2206
|
+
<InlineTool icon="◈" pending="Searching web..." complete={stringValue(props.input.query)} part={props.part}>
|
|
2207
|
+
{webSearchProviderLabel(props.metadata.provider)} "{stringValue(props.input.query)}"{" "}
|
|
2208
|
+
<Show when={numberValue(props.metadata.numResults)}>({numberValue(props.metadata.numResults)} results)</Show>
|
|
2209
|
+
</InlineTool>
|
|
2210
|
+
)
|
|
2211
|
+
}
|
|
2212
|
+
|
|
2213
|
+
function Task(props: ToolProps) {
|
|
2214
|
+
const { theme } = useTheme()
|
|
2215
|
+
const { navigate } = useRoute()
|
|
2216
|
+
const sync = useSync()
|
|
2217
|
+
const dialog = useDialog()
|
|
2218
|
+
|
|
2219
|
+
onMount(() => {
|
|
2220
|
+
const sessionID = stringValue(props.metadata.sessionId)
|
|
2221
|
+
if (sessionID && !sync.data.message[sessionID]?.length) void sync.session.sync(sessionID)
|
|
2222
|
+
})
|
|
2223
|
+
|
|
2224
|
+
const sessionID = createMemo(() => stringValue(props.metadata.sessionId))
|
|
2225
|
+
const messages = createMemo(() => sync.data.message[sessionID() ?? ""] ?? [])
|
|
2226
|
+
|
|
2227
|
+
const tools = createMemo(() => {
|
|
2228
|
+
return messages().flatMap((msg) =>
|
|
2229
|
+
(sync.data.part[msg.id] ?? [])
|
|
2230
|
+
.filter((part): part is ToolPart => part.type === "tool")
|
|
2231
|
+
.map((part) => ({ tool: part.tool, state: part.state })),
|
|
2232
|
+
)
|
|
2233
|
+
})
|
|
2234
|
+
|
|
2235
|
+
const current = createMemo(() =>
|
|
2236
|
+
tools().findLast((x) => (x.state.status === "running" || x.state.status === "completed") && x.state.title),
|
|
2237
|
+
)
|
|
2238
|
+
|
|
2239
|
+
const status = createMemo(() => sync.data.session_status[sessionID() ?? ""])
|
|
2240
|
+
const isRunning = createMemo(() => {
|
|
2241
|
+
const value = status()
|
|
2242
|
+
return (
|
|
2243
|
+
props.part.state.status === "running" ||
|
|
2244
|
+
(props.metadata.background === true && value !== undefined && value.type !== "idle")
|
|
2245
|
+
)
|
|
2246
|
+
})
|
|
2247
|
+
const retry = createMemo(() => {
|
|
2248
|
+
const value = status()
|
|
2249
|
+
if (value?.type !== "retry") return
|
|
2250
|
+
return value
|
|
2251
|
+
})
|
|
2252
|
+
|
|
2253
|
+
const duration = createMemo(() => {
|
|
2254
|
+
const first = messages().find((x) => x.role === "user")?.time.created
|
|
2255
|
+
const assistant = messages().findLast((x) => x.role === "assistant")?.time.completed
|
|
2256
|
+
if (!first || !assistant) return 0
|
|
2257
|
+
return assistant - first
|
|
2258
|
+
})
|
|
2259
|
+
|
|
2260
|
+
const content = createMemo(() => {
|
|
2261
|
+
const description = stringValue(props.input.description)
|
|
2262
|
+
if (!description) return ""
|
|
2263
|
+
let content = [
|
|
2264
|
+
formatSubagentTitle(
|
|
2265
|
+
Locale.titlecase(stringValue(props.input.subagent_type) ?? "General"),
|
|
2266
|
+
description,
|
|
2267
|
+
props.metadata.background === true,
|
|
2268
|
+
),
|
|
2269
|
+
]
|
|
2270
|
+
|
|
2271
|
+
const retrying = retry()
|
|
2272
|
+
if (isRunning() && retrying) {
|
|
2273
|
+
content.push(`↳ ${formatSubagentRetry(retrying.attempt, Locale.truncate(retrying.message, 80))}`)
|
|
2274
|
+
} else if (isRunning() && tools().length > 0) {
|
|
2275
|
+
if (current()) {
|
|
2276
|
+
const state = current()!.state
|
|
2277
|
+
const title = state.status === "running" || state.status === "completed" ? state.title : undefined
|
|
2278
|
+
content.push(`↳ ${Locale.titlecase(current()!.tool)} ${title}`)
|
|
2279
|
+
} else content.push(`↳ ${formatSubagentToolcalls(tools().length)}`)
|
|
2280
|
+
}
|
|
2281
|
+
|
|
2282
|
+
if (!isRunning() && props.part.state.status === "completed") {
|
|
2283
|
+
content.push(`↳ ${formatCompletedSubagentDetail(tools().length, Locale.duration(duration()))}`)
|
|
2284
|
+
}
|
|
2285
|
+
|
|
2286
|
+
return content.join("\n")
|
|
2287
|
+
})
|
|
2288
|
+
|
|
2289
|
+
return (
|
|
2290
|
+
<InlineTool
|
|
2291
|
+
icon={props.part.state.status === "completed" ? "✓" : "│"}
|
|
2292
|
+
separate={true}
|
|
2293
|
+
color={retry() ? theme.error : undefined}
|
|
2294
|
+
spinner={isRunning()}
|
|
2295
|
+
complete={stringValue(props.input.description)}
|
|
2296
|
+
pending="Delegating..."
|
|
2297
|
+
part={props.part}
|
|
2298
|
+
onClick={() => {
|
|
2299
|
+
if (sessionID()) {
|
|
2300
|
+
navigate({ type: "session", sessionID: sessionID()! })
|
|
2301
|
+
}
|
|
2302
|
+
const status = retry()
|
|
2303
|
+
if (status) void DialogAlert.show(dialog, "Retry Error", status.message)
|
|
2304
|
+
}}
|
|
2305
|
+
>
|
|
2306
|
+
{content()}
|
|
2307
|
+
</InlineTool>
|
|
2308
|
+
)
|
|
2309
|
+
}
|
|
2310
|
+
|
|
2311
|
+
export function formatSubagentToolcalls(count: number) {
|
|
2312
|
+
return `${count} toolcall${count === 1 ? "" : "s"}`
|
|
2313
|
+
}
|
|
2314
|
+
|
|
2315
|
+
export function formatSubagentTitle(agent: string, description: string, background: boolean) {
|
|
2316
|
+
return `${agent} Task${background ? " (background)" : ""} — ${description}`
|
|
2317
|
+
}
|
|
2318
|
+
|
|
2319
|
+
export function formatSubagentRetry(attempt: number, message: string) {
|
|
2320
|
+
return `Retrying (attempt ${attempt}) · ${message}`
|
|
2321
|
+
}
|
|
2322
|
+
|
|
2323
|
+
export function formatCompletedSubagentDetail(toolcalls: number, duration: string) {
|
|
2324
|
+
if (toolcalls === 0) return duration
|
|
2325
|
+
return `${formatSubagentToolcalls(toolcalls)} · ${duration}`
|
|
2326
|
+
}
|
|
2327
|
+
|
|
2328
|
+
type ExecuteCall = { tool: string; status: "running" | "completed" | "error"; input?: Record<string, unknown> }
|
|
2329
|
+
|
|
2330
|
+
function executeCalls(value: unknown): ExecuteCall[] {
|
|
2331
|
+
if (!Array.isArray(value)) return []
|
|
2332
|
+
return value.flatMap((call) => {
|
|
2333
|
+
const item = recordValue(call)
|
|
2334
|
+
const tool = stringValue(item?.tool)
|
|
2335
|
+
const status = stringValue(item?.status)
|
|
2336
|
+
if (!tool || !status || !["running", "completed", "error"].includes(status)) return []
|
|
2337
|
+
return [{ tool, status: status as ExecuteCall["status"], input: recordValue(item?.input) }]
|
|
2338
|
+
})
|
|
2339
|
+
}
|
|
2340
|
+
|
|
2341
|
+
// The `execute` tool streams child tool calls through metadata, not a child session like Task.
|
|
2342
|
+
function Execute(props: ToolProps) {
|
|
2343
|
+
const ctx = use()
|
|
2344
|
+
const { theme } = useTheme()
|
|
2345
|
+
const isLoading = createMemo(() => props.part.state.status === "pending" || props.part.state.status === "running")
|
|
2346
|
+
const calls = createMemo(() => executeCalls(props.metadata.toolCalls))
|
|
2347
|
+
const output = createMemo(() => stripAnsi(props.output?.trim() ?? ""))
|
|
2348
|
+
const hasRuntimeError = createMemo(() => props.metadata.error === true)
|
|
2349
|
+
const outputPreview = createMemo(() => collapseToolOutput(output(), 4, 4 * Math.max(20, ctx.width - 6)).output)
|
|
2350
|
+
const showOutput = createMemo(() => output() && hasRuntimeError())
|
|
2351
|
+
const content = createMemo(() => {
|
|
2352
|
+
const lines = ["execute"]
|
|
2353
|
+
for (const call of calls()) {
|
|
2354
|
+
const args = input(call.input ?? {})
|
|
2355
|
+
lines.push(`↳ ${call.tool}${args ? ` ${args}` : ""}${call.status === "error" ? " (failed)" : ""}`)
|
|
2356
|
+
}
|
|
2357
|
+
return lines.join("\n")
|
|
2358
|
+
})
|
|
2359
|
+
|
|
2360
|
+
return (
|
|
2361
|
+
<>
|
|
2362
|
+
<InlineTool
|
|
2363
|
+
icon={hasRuntimeError() ? "✗" : props.part.state.status === "completed" ? "✓" : "│"}
|
|
2364
|
+
color={hasRuntimeError() ? theme.error : undefined}
|
|
2365
|
+
spinner={isLoading()}
|
|
2366
|
+
pending="execute"
|
|
2367
|
+
complete={true}
|
|
2368
|
+
part={props.part}
|
|
2369
|
+
>
|
|
2370
|
+
{content()}
|
|
2371
|
+
</InlineTool>
|
|
2372
|
+
<Show when={showOutput()}>
|
|
2373
|
+
<box paddingLeft={3}>
|
|
2374
|
+
<For each={outputPreview().split("\n")}>
|
|
2375
|
+
{(line, index) => (
|
|
2376
|
+
<text paddingLeft={3} fg={theme.error}>
|
|
2377
|
+
{index() === 0 ? "↳ " : " "}
|
|
2378
|
+
{line}
|
|
2379
|
+
</text>
|
|
2380
|
+
)}
|
|
2381
|
+
</For>
|
|
2382
|
+
</box>
|
|
2383
|
+
</Show>
|
|
2384
|
+
</>
|
|
2385
|
+
)
|
|
2386
|
+
}
|
|
2387
|
+
|
|
2388
|
+
function Edit(props: ToolProps) {
|
|
2389
|
+
const ctx = use()
|
|
2390
|
+
const { theme, syntax } = useTheme()
|
|
2391
|
+
const pathFormatter = usePathFormatter()
|
|
2392
|
+
|
|
2393
|
+
const view = createMemo(() => {
|
|
2394
|
+
const diffStyle = ctx.tui.diff_style
|
|
2395
|
+
if (diffStyle === "stacked") return "unified"
|
|
2396
|
+
// Default to "auto" behavior
|
|
2397
|
+
return ctx.width > 120 ? "split" : "unified"
|
|
2398
|
+
})
|
|
2399
|
+
|
|
2400
|
+
const ft = createMemo(() => filetype(stringValue(props.input.filePath)))
|
|
2401
|
+
|
|
2402
|
+
const diffContent = createMemo(() => stringValue(props.metadata.diff) ?? "")
|
|
2403
|
+
|
|
2404
|
+
return (
|
|
2405
|
+
<Switch>
|
|
2406
|
+
<Match when={stringValue(props.metadata.diff) !== undefined}>
|
|
2407
|
+
<BlockTool title={"← Edit " + pathFormatter.format(stringValue(props.input.filePath))} part={props.part}>
|
|
2408
|
+
<box paddingLeft={1}>
|
|
2409
|
+
<diff
|
|
2410
|
+
diff={diffContent()}
|
|
2411
|
+
view={view()}
|
|
2412
|
+
filetype={ft()}
|
|
2413
|
+
syntaxStyle={syntax()}
|
|
2414
|
+
showLineNumbers={true}
|
|
2415
|
+
width="100%"
|
|
2416
|
+
wrapMode={ctx.diffWrapMode()}
|
|
2417
|
+
fg={theme.text}
|
|
2418
|
+
addedBg={theme.diffAddedBg}
|
|
2419
|
+
removedBg={theme.diffRemovedBg}
|
|
2420
|
+
contextBg={theme.diffContextBg}
|
|
2421
|
+
addedSignColor={theme.diffHighlightAdded}
|
|
2422
|
+
removedSignColor={theme.diffHighlightRemoved}
|
|
2423
|
+
lineNumberFg={theme.diffLineNumber}
|
|
2424
|
+
lineNumberBg={theme.diffContextBg}
|
|
2425
|
+
addedLineNumberBg={theme.diffAddedLineNumberBg}
|
|
2426
|
+
removedLineNumberBg={theme.diffRemovedLineNumberBg}
|
|
2427
|
+
/>
|
|
2428
|
+
</box>
|
|
2429
|
+
<Diagnostics diagnostics={props.metadata.diagnostics} filePath={stringValue(props.input.filePath) ?? ""} />
|
|
2430
|
+
</BlockTool>
|
|
2431
|
+
</Match>
|
|
2432
|
+
<Match when={true}>
|
|
2433
|
+
<InlineTool icon="←" pending="Preparing edit..." complete={stringValue(props.input.filePath)} part={props.part}>
|
|
2434
|
+
Edit {pathFormatter.format(stringValue(props.input.filePath))} {input({ replaceAll: props.input.replaceAll })}
|
|
2435
|
+
</InlineTool>
|
|
2436
|
+
</Match>
|
|
2437
|
+
</Switch>
|
|
2438
|
+
)
|
|
2439
|
+
}
|
|
2440
|
+
|
|
2441
|
+
function ApplyPatch(props: ToolProps) {
|
|
2442
|
+
const ctx = use()
|
|
2443
|
+
const { theme, syntax } = useTheme()
|
|
2444
|
+
const pathFormatter = usePathFormatter()
|
|
2445
|
+
|
|
2446
|
+
const files = createMemo(() => parseApplyPatchFiles(props.metadata.files))
|
|
2447
|
+
|
|
2448
|
+
const view = createMemo(() => {
|
|
2449
|
+
const diffStyle = ctx.tui.diff_style
|
|
2450
|
+
if (diffStyle === "stacked") return "unified"
|
|
2451
|
+
return ctx.width > 120 ? "split" : "unified"
|
|
2452
|
+
})
|
|
2453
|
+
|
|
2454
|
+
function Diff(p: { diff: string; filePath: string }) {
|
|
2455
|
+
return (
|
|
2456
|
+
<box paddingLeft={1}>
|
|
2457
|
+
<diff
|
|
2458
|
+
diff={p.diff}
|
|
2459
|
+
view={view()}
|
|
2460
|
+
filetype={filetype(p.filePath)}
|
|
2461
|
+
syntaxStyle={syntax()}
|
|
2462
|
+
showLineNumbers={true}
|
|
2463
|
+
width="100%"
|
|
2464
|
+
wrapMode={ctx.diffWrapMode()}
|
|
2465
|
+
fg={theme.text}
|
|
2466
|
+
addedBg={theme.diffAddedBg}
|
|
2467
|
+
removedBg={theme.diffRemovedBg}
|
|
2468
|
+
contextBg={theme.diffContextBg}
|
|
2469
|
+
addedSignColor={theme.diffHighlightAdded}
|
|
2470
|
+
removedSignColor={theme.diffHighlightRemoved}
|
|
2471
|
+
lineNumberFg={theme.diffLineNumber}
|
|
2472
|
+
lineNumberBg={theme.diffContextBg}
|
|
2473
|
+
addedLineNumberBg={theme.diffAddedLineNumberBg}
|
|
2474
|
+
removedLineNumberBg={theme.diffRemovedLineNumberBg}
|
|
2475
|
+
/>
|
|
2476
|
+
</box>
|
|
2477
|
+
)
|
|
2478
|
+
}
|
|
2479
|
+
|
|
2480
|
+
function title(file: { type: string; relativePath: string; filePath: string; deletions: number }) {
|
|
2481
|
+
if (file.type === "delete") return "# Deleted " + file.relativePath
|
|
2482
|
+
if (file.type === "add") return "# Created " + file.relativePath
|
|
2483
|
+
if (file.type === "move") return "# Moved " + pathFormatter.format(file.filePath) + " → " + file.relativePath
|
|
2484
|
+
return "← Patched " + file.relativePath
|
|
2485
|
+
}
|
|
2486
|
+
|
|
2487
|
+
return (
|
|
2488
|
+
<Switch>
|
|
2489
|
+
<Match when={files().length > 0}>
|
|
2490
|
+
<For each={files()}>
|
|
2491
|
+
{(file) => (
|
|
2492
|
+
<BlockTool title={title(file)} part={props.part}>
|
|
2493
|
+
<Show
|
|
2494
|
+
when={file.type !== "delete"}
|
|
2495
|
+
fallback={
|
|
2496
|
+
<text fg={theme.diffRemoved}>
|
|
2497
|
+
-{file.deletions} line{file.deletions !== 1 ? "s" : ""}
|
|
2498
|
+
</text>
|
|
2499
|
+
}
|
|
2500
|
+
>
|
|
2501
|
+
<Diff diff={file.patch} filePath={file.filePath} />
|
|
2502
|
+
<Diagnostics diagnostics={props.metadata.diagnostics} filePath={file.movePath ?? file.filePath} />
|
|
2503
|
+
</Show>
|
|
2504
|
+
</BlockTool>
|
|
2505
|
+
)}
|
|
2506
|
+
</For>
|
|
2507
|
+
</Match>
|
|
2508
|
+
<Match when={true}>
|
|
2509
|
+
<InlineTool icon="%" pending="Preparing patch..." failure="Patch failed" complete={false} part={props.part}>
|
|
2510
|
+
Patch
|
|
2511
|
+
</InlineTool>
|
|
2512
|
+
</Match>
|
|
2513
|
+
</Switch>
|
|
2514
|
+
)
|
|
2515
|
+
}
|
|
2516
|
+
|
|
2517
|
+
function TodoWrite(props: ToolProps) {
|
|
2518
|
+
const todos = createMemo(() => parseTodos(props.input.todos))
|
|
2519
|
+
return (
|
|
2520
|
+
<Switch>
|
|
2521
|
+
<Match when={parseTodos(props.metadata.todos).length}>
|
|
2522
|
+
<BlockTool title="# Todos" part={props.part}>
|
|
2523
|
+
<box>
|
|
2524
|
+
<For each={todos()}>{(todo) => <TodoItem status={todo.status} content={todo.content} />}</For>
|
|
2525
|
+
</box>
|
|
2526
|
+
</BlockTool>
|
|
2527
|
+
</Match>
|
|
2528
|
+
<Match when={true}>
|
|
2529
|
+
<InlineTool
|
|
2530
|
+
icon="⚙"
|
|
2531
|
+
pending="Updating todos..."
|
|
2532
|
+
failure="Todo update failed"
|
|
2533
|
+
complete={false}
|
|
2534
|
+
part={props.part}
|
|
2535
|
+
>
|
|
2536
|
+
Updating todos...
|
|
2537
|
+
</InlineTool>
|
|
2538
|
+
</Match>
|
|
2539
|
+
</Switch>
|
|
2540
|
+
)
|
|
2541
|
+
}
|
|
2542
|
+
|
|
2543
|
+
function Question(props: ToolProps) {
|
|
2544
|
+
const { theme } = useTheme()
|
|
2545
|
+
const questions = createMemo(() => parseQuestions(props.input.questions))
|
|
2546
|
+
const answers = createMemo(() => parseQuestionAnswers(props.metadata.answers))
|
|
2547
|
+
const count = createMemo(() => questions().length)
|
|
2548
|
+
|
|
2549
|
+
function format(answer?: ReadonlyArray<string>) {
|
|
2550
|
+
if (!answer?.length) return "(no answer)"
|
|
2551
|
+
return answer.join(", ")
|
|
2552
|
+
}
|
|
2553
|
+
|
|
2554
|
+
return (
|
|
2555
|
+
<Switch>
|
|
2556
|
+
<Match when={answers()}>
|
|
2557
|
+
<BlockTool title="# Questions" part={props.part}>
|
|
2558
|
+
<box gap={1}>
|
|
2559
|
+
<For each={questions()}>
|
|
2560
|
+
{(q, i) => (
|
|
2561
|
+
<box flexDirection="column">
|
|
2562
|
+
<text fg={theme.textMuted}>{q.question}</text>
|
|
2563
|
+
<text fg={theme.text}>{format(answers()?.[i()])}</text>
|
|
2564
|
+
</box>
|
|
2565
|
+
)}
|
|
2566
|
+
</For>
|
|
2567
|
+
</box>
|
|
2568
|
+
</BlockTool>
|
|
2569
|
+
</Match>
|
|
2570
|
+
<Match when={true}>
|
|
2571
|
+
<InlineTool icon="→" pending="Asking questions..." complete={count()} part={props.part}>
|
|
2572
|
+
Asked {count()} question{count() !== 1 ? "s" : ""}
|
|
2573
|
+
</InlineTool>
|
|
2574
|
+
</Match>
|
|
2575
|
+
</Switch>
|
|
2576
|
+
)
|
|
2577
|
+
}
|
|
2578
|
+
|
|
2579
|
+
function Skill(props: ToolProps) {
|
|
2580
|
+
return (
|
|
2581
|
+
<InlineTool icon="→" pending="Loading skill..." complete={stringValue(props.input.name)} part={props.part}>
|
|
2582
|
+
Skill "{stringValue(props.input.name)}"
|
|
2583
|
+
</InlineTool>
|
|
2584
|
+
)
|
|
2585
|
+
}
|
|
2586
|
+
|
|
2587
|
+
function Diagnostics(props: { diagnostics: unknown; filePath: string }) {
|
|
2588
|
+
const { theme } = useTheme()
|
|
2589
|
+
const terminalEnvironment = useTuiTerminalEnvironment()
|
|
2590
|
+
const errors = createMemo(() => {
|
|
2591
|
+
const normalized = normalizePath(
|
|
2592
|
+
typeof props.filePath === "string" ? props.filePath : "",
|
|
2593
|
+
terminalEnvironment.platform,
|
|
2594
|
+
)
|
|
2595
|
+
return parseDiagnostics(props.diagnostics, normalized)
|
|
2596
|
+
})
|
|
2597
|
+
|
|
2598
|
+
return (
|
|
2599
|
+
<Show when={errors().length}>
|
|
2600
|
+
<box>
|
|
2601
|
+
<For each={errors()}>
|
|
2602
|
+
{(diagnostic) => (
|
|
2603
|
+
<text fg={theme.error}>
|
|
2604
|
+
Error [{diagnostic.range.start.line + 1}:{diagnostic.range.start.character + 1}] {diagnostic.message}
|
|
2605
|
+
</text>
|
|
2606
|
+
)}
|
|
2607
|
+
</For>
|
|
2608
|
+
</box>
|
|
2609
|
+
</Show>
|
|
2610
|
+
)
|
|
2611
|
+
}
|
|
2612
|
+
|
|
2613
|
+
function input(input: Record<string, unknown>, omit?: string[]): string {
|
|
2614
|
+
const primitives = Object.entries(input).filter(([key, value]) => {
|
|
2615
|
+
if (omit?.includes(key)) return false
|
|
2616
|
+
return typeof value === "string" || typeof value === "number" || typeof value === "boolean"
|
|
2617
|
+
})
|
|
2618
|
+
if (primitives.length === 0) return ""
|
|
2619
|
+
return `[${primitives.map(([key, value]) => `${key}=${value}`).join(", ")}]`
|
|
2620
|
+
}
|
|
2621
|
+
|
|
2622
|
+
function stringValue(value: unknown) {
|
|
2623
|
+
return typeof value === "string" ? value : undefined
|
|
2624
|
+
}
|
|
2625
|
+
|
|
2626
|
+
function numberValue(value: unknown) {
|
|
2627
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined
|
|
2628
|
+
}
|
|
2629
|
+
|
|
2630
|
+
const toolDisplays = new Set([
|
|
2631
|
+
"bash",
|
|
2632
|
+
"glob",
|
|
2633
|
+
"read",
|
|
2634
|
+
"grep",
|
|
2635
|
+
"webfetch",
|
|
2636
|
+
"websearch",
|
|
2637
|
+
"write",
|
|
2638
|
+
"edit",
|
|
2639
|
+
"task",
|
|
2640
|
+
"apply_patch",
|
|
2641
|
+
"todowrite",
|
|
2642
|
+
"question",
|
|
2643
|
+
"skill",
|
|
2644
|
+
"execute",
|
|
2645
|
+
])
|
|
2646
|
+
|
|
2647
|
+
export function toolDisplay(tool: string) {
|
|
2648
|
+
return toolDisplays.has(tool) ? tool : "generic"
|
|
2649
|
+
}
|
|
2650
|
+
|
|
2651
|
+
function recordValue(value: unknown): Record<string, unknown> | undefined {
|
|
2652
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return
|
|
2653
|
+
return value as Record<string, unknown>
|
|
2654
|
+
}
|
|
2655
|
+
|
|
2656
|
+
export function parseApplyPatchFiles(value: unknown) {
|
|
2657
|
+
if (!Array.isArray(value)) return []
|
|
2658
|
+
return value.flatMap((item) => {
|
|
2659
|
+
const file = recordValue(item)
|
|
2660
|
+
if (!file) return []
|
|
2661
|
+
const type = stringValue(file.type)
|
|
2662
|
+
const relativePath = stringValue(file.relativePath)
|
|
2663
|
+
const filePath = stringValue(file.filePath)
|
|
2664
|
+
const patch = stringValue(file.patch)
|
|
2665
|
+
const deletions = numberValue(file.deletions)
|
|
2666
|
+
if (!type || !relativePath || !filePath || patch === undefined || deletions === undefined) return []
|
|
2667
|
+
return [{ type, relativePath, filePath, patch, deletions, movePath: stringValue(file.movePath) }]
|
|
2668
|
+
})
|
|
2669
|
+
}
|
|
2670
|
+
|
|
2671
|
+
export function parseTodos(value: unknown) {
|
|
2672
|
+
if (!Array.isArray(value)) return []
|
|
2673
|
+
return value.flatMap((item) => {
|
|
2674
|
+
const todo = recordValue(item)
|
|
2675
|
+
const status = stringValue(todo?.status)
|
|
2676
|
+
const content = stringValue(todo?.content)
|
|
2677
|
+
return status && content ? [{ status, content }] : []
|
|
2678
|
+
})
|
|
2679
|
+
}
|
|
2680
|
+
|
|
2681
|
+
export function parseQuestions(value: unknown) {
|
|
2682
|
+
if (!Array.isArray(value)) return []
|
|
2683
|
+
return value.flatMap((item) => {
|
|
2684
|
+
const question = stringValue(recordValue(item)?.question)
|
|
2685
|
+
return question ? [{ question }] : []
|
|
2686
|
+
})
|
|
2687
|
+
}
|
|
2688
|
+
|
|
2689
|
+
export function parseQuestionAnswers(value: unknown) {
|
|
2690
|
+
if (!Array.isArray(value)) return
|
|
2691
|
+
return value.map((answer) =>
|
|
2692
|
+
Array.isArray(answer) ? answer.filter((item): item is string => typeof item === "string") : [],
|
|
2693
|
+
)
|
|
2694
|
+
}
|
|
2695
|
+
|
|
2696
|
+
export function parseDiagnostics(value: unknown, filePath: string) {
|
|
2697
|
+
const diagnostics = recordValue(value)?.[filePath]
|
|
2698
|
+
if (!Array.isArray(diagnostics)) return []
|
|
2699
|
+
return diagnostics
|
|
2700
|
+
.flatMap((item) => {
|
|
2701
|
+
const diagnostic = recordValue(item)
|
|
2702
|
+
const start = recordValue(recordValue(diagnostic?.range)?.start)
|
|
2703
|
+
const line = numberValue(start?.line)
|
|
2704
|
+
const character = numberValue(start?.character)
|
|
2705
|
+
const message = stringValue(diagnostic?.message)
|
|
2706
|
+
if (diagnostic?.severity !== 1 || line === undefined || character === undefined || !message) return []
|
|
2707
|
+
return [{ range: { start: { line, character } }, message }]
|
|
2708
|
+
})
|
|
2709
|
+
.slice(0, 3)
|
|
2710
|
+
}
|