@xp266/dshtui 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/dshtui.js +65 -0
- package/cordis.patch.yml +81 -0
- package/lib/contract/index.d.mts +2 -0
- package/lib/contract/index.mjs +5 -0
- package/lib/dialog.d.mts +156 -0
- package/lib/dialog.mjs +3 -0
- package/lib/index-DqnaSXqP.d.mts +823 -0
- package/lib/index.d.mts +17 -0
- package/lib/index.mjs +11404 -0
- package/lib/prod-react-Dzof4qJx.mjs +4 -0
- package/lib/surface-DdxzIgIY.mjs +3126 -0
- package/lib/vendor.d.mts +3 -0
- package/lib/vendor.mjs +4 -0
- package/package.json +128 -0
- package/src/apply-theme.ts +8 -0
- package/src/boot-log.ts +35 -0
- package/src/chat/blocks.ts +41 -0
- package/src/chat/bridge.ts +1076 -0
- package/src/chat/builtin-tool-views.ts +53 -0
- package/src/chat/chat-face.ts +16 -0
- package/src/chat/chat-nodes.ts +53 -0
- package/src/chat/efforts.ts +4 -0
- package/src/chat/interactions.ts +329 -0
- package/src/chat/models.ts +263 -0
- package/src/chat/partial-json.ts +125 -0
- package/src/chat/presets.ts +26 -0
- package/src/chat/question-view.ts +106 -0
- package/src/chat/retry-status.ts +33 -0
- package/src/chat/session-groups.ts +34 -0
- package/src/chat/session-list.ts +311 -0
- package/src/chat/store.ts +636 -0
- package/src/chat/todo-view.ts +66 -0
- package/src/chat/tool-view.ts +311 -0
- package/src/chat/tool-views.ts +114 -0
- package/src/contract/index.ts +704 -0
- package/src/contract/upstream.ts +165 -0
- package/src/core/caret-nonce.ts +19 -0
- package/src/core/composer-layout.ts +65 -0
- package/src/core/edit.ts +152 -0
- package/src/core/field-view.ts +52 -0
- package/src/core/fields.ts +185 -0
- package/src/core/metrics.ts +26 -0
- package/src/core/paste.ts +149 -0
- package/src/core/segments.ts +112 -0
- package/src/core/text.ts +290 -0
- package/src/dialog.ts +11 -0
- package/src/env.ts +38 -0
- package/src/harness-home.ts +22 -0
- package/src/hot-theme.ts +56 -0
- package/src/index.tsx +185 -0
- package/src/kernel/registry.ts +102 -0
- package/src/kernel/surface.ts +33 -0
- package/src/log.ts +249 -0
- package/src/model/message.ts +89 -0
- package/src/model/selection.ts +63 -0
- package/src/performance-guard.ts +28 -0
- package/src/runtime/prod-react.ts +10 -0
- package/src/terminal/background.ts +66 -0
- package/src/terminal/capabilities.ts +54 -0
- package/src/terminal/clipboard-backends.ts +69 -0
- package/src/terminal/clipboard.ts +161 -0
- package/src/terminal/cursor-shape.ts +13 -0
- package/src/terminal/glyphs.ts +44 -0
- package/src/terminal/mouse.ts +154 -0
- package/src/terminal/probe.ts +61 -0
- package/src/terminal/screen.ts +349 -0
- package/src/theme-settings.ts +31 -0
- package/src/theme.ts +319 -0
- package/src/ui/app.tsx +454 -0
- package/src/ui/chrome/caps.tsx +9 -0
- package/src/ui/chrome/hint-service.ts +87 -0
- package/src/ui/chrome/input-status.ts +27 -0
- package/src/ui/chrome/key-gate.tsx +29 -0
- package/src/ui/chrome/status-bar.tsx +147 -0
- package/src/ui/contributions.ts +47 -0
- package/src/ui/dialog/defaults-dialog.tsx +152 -0
- package/src/ui/dialog/dialog-item.tsx +38 -0
- package/src/ui/dialog/dialog.tsx +386 -0
- package/src/ui/dialog/effort-dialog.tsx +45 -0
- package/src/ui/dialog/geometry.ts +131 -0
- package/src/ui/dialog/items.ts +124 -0
- package/src/ui/dialog/list-dialog.tsx +63 -0
- package/src/ui/dialog/models-dialog.tsx +203 -0
- package/src/ui/dialog/presets-dialog.tsx +46 -0
- package/src/ui/dialog/providers-dialog.tsx +313 -0
- package/src/ui/dialog/sessions-dialog.tsx +163 -0
- package/src/ui/dialog/sizes.ts +7 -0
- package/src/ui/dialog/status-lines.ts +11 -0
- package/src/ui/dialog/todo-dialog.tsx +29 -0
- package/src/ui/dialog/use-dialog-input.ts +186 -0
- package/src/ui/extension-point.ts +188 -0
- package/src/ui/home-logo.ts +132 -0
- package/src/ui/hooks/use-async-action.ts +25 -0
- package/src/ui/hooks/use-async-list.ts +71 -0
- package/src/ui/hooks/use-caret.ts +24 -0
- package/src/ui/hooks/use-chat-events.ts +153 -0
- package/src/ui/hooks/use-mouse-selection.ts +352 -0
- package/src/ui/hooks/use-scroll.ts +49 -0
- package/src/ui/hooks/use-terminal-size.ts +20 -0
- package/src/ui/input/commands.ts +118 -0
- package/src/ui/input/composer-bus.ts +14 -0
- package/src/ui/input/composer-fields.ts +111 -0
- package/src/ui/input/composer-keys.ts +21 -0
- package/src/ui/input/composer-paste.ts +20 -0
- package/src/ui/input/input-bar.tsx +279 -0
- package/src/ui/input/use-composer.ts +495 -0
- package/src/ui/key-arbiter.ts +37 -0
- package/src/ui/keymap.ts +15 -0
- package/src/ui/layout-service.ts +77 -0
- package/src/ui/message/layout.ts +763 -0
- package/src/ui/message/md/block.ts +247 -0
- package/src/ui/message/md/engine.ts +430 -0
- package/src/ui/message/md/extensions.ts +58 -0
- package/src/ui/message/md/highlight.ts +626 -0
- package/src/ui/message/md/index.ts +14 -0
- package/src/ui/message/md/inline.ts +97 -0
- package/src/ui/message/md/palette.ts +87 -0
- package/src/ui/message/message-list.tsx +69 -0
- package/src/ui/message/message-row.tsx +137 -0
- package/src/ui/message/message-views.ts +16 -0
- package/src/ui/message/renderers.ts +39 -0
- package/src/ui/message/tool-diff.ts +188 -0
- package/src/ui/message/warmup.ts +41 -0
- package/src/ui/message/wave.ts +98 -0
- package/src/ui/overlay.ts +22 -0
- package/src/ui/panels/approval-panel.tsx +193 -0
- package/src/ui/panels/exports.ts +3 -0
- package/src/ui/panels/question-model.ts +424 -0
- package/src/ui/panels/question-panel.tsx +154 -0
- package/src/ui/panels/surface.tsx +106 -0
- package/src/ui/panels-builtin.tsx +86 -0
- package/src/ui/pointer/builtins.ts +281 -0
- package/src/ui/pointer/registry.ts +25 -0
- package/src/ui/region.tsx +31 -0
- package/src/ui/selection/service.ts +92 -0
- package/src/ui/selection-registry.ts +88 -0
- package/src/ui/selection.tsx +148 -0
- package/src/ui/spinner-tick.tsx +24 -0
- package/src/ui/use-available-windows.ts +31 -0
- package/src/ui/widgets/actions.tsx +84 -0
- package/src/ui/widgets/button.tsx +57 -0
- package/src/ui/widgets/checkbox.tsx +58 -0
- package/src/ui/widgets/header.tsx +36 -0
- package/src/ui/widgets/index.ts +8 -0
- package/src/ui/widgets/input.tsx +76 -0
- package/src/ui/widgets/registry.ts +32 -0
- package/src/ui/widgets/search.tsx +59 -0
- package/src/ui/widgets/select.tsx +97 -0
- package/src/ui/widgets/static.tsx +37 -0
- package/src/ui/widgets/types.ts +1 -0
- package/src/ui/window-services-bridge.ts +71 -0
- package/src/ui/window-services.ts +20 -0
- package/src/ui/windows/models-window.tsx +15 -0
- package/src/ui/windows/providers-window.tsx +14 -0
- package/src/ui/windows/service-window.tsx +18 -0
- package/src/ui/windows/sessions-window.tsx +25 -0
- package/src/ui/windows-builtin.tsx +94 -0
- package/src/ui/windows.ts +18 -0
- package/src/vendor.ts +12 -0
|
@@ -0,0 +1,1076 @@
|
|
|
1
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
2
|
+
import type {} from '@deepseek-ai/dsh-agent'
|
|
3
|
+
import type {} from '@deepseek-ai/dsh-agent-loop'
|
|
4
|
+
import type {} from '@deepseek-ai/dsh-credentials'
|
|
5
|
+
import type {} from '@deepseek-ai/dsh-settings'
|
|
6
|
+
import type {} from '@deepseek-ai/dsh-agent-default-model'
|
|
7
|
+
import type {} from '@deepseek-ai/dsh-agent-presets'
|
|
8
|
+
import { resolveSessionPreset } from '@deepseek-ai/dsh-agent-presets'
|
|
9
|
+
import { installModelSelection } from '@deepseek-ai/dsh-agent'
|
|
10
|
+
import type { Agent, AgentHandle, AgentOptions, ModelSelection, ModelSelectionRef } from '@deepseek-ai/dsh-agent'
|
|
11
|
+
import type { ContentBlock, LlmDiscoveredModel } from '@deepseek-ai/dsh-llm'
|
|
12
|
+
import { ReasoningEffortId } from '@deepseek-ai/dsh-llm'
|
|
13
|
+
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
|
14
|
+
import { SessionId } from '@deepseek-ai/dsh-session'
|
|
15
|
+
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
|
16
|
+
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
|
17
|
+
import { textFromBlocks } from './blocks.ts'
|
|
18
|
+
import { imageMediaTypeOf } from '../core/paste.ts'
|
|
19
|
+
import type { PendingImage } from '../core/paste.ts'
|
|
20
|
+
import {
|
|
21
|
+
deleteProviderProfile,
|
|
22
|
+
fetchCustomModels,
|
|
23
|
+
fetchProviderModels,
|
|
24
|
+
deleteModelEntry,
|
|
25
|
+
listConfiguredModels,
|
|
26
|
+
listProviderDirectory,
|
|
27
|
+
readModelEntries,
|
|
28
|
+
saveCustomProvider,
|
|
29
|
+
saveModelEntry,
|
|
30
|
+
saveProviderKey,
|
|
31
|
+
saveProviderModels,
|
|
32
|
+
} from './models.ts'
|
|
33
|
+
import type { ConfiguredModel, CustomProviderForm, DescribedModel, ModelEntryConfig, OfficialProvider, SettingsPathOp } from './models.ts'
|
|
34
|
+
import { formatDiffDiffs, dedupeTitle, diffLineGroups, pathFromTitle, pickPrimaryParam, relativize, remainingArgsJson, truncateSummary, terminalCallBody, genericCallBody, genericCallHeader, recoveryLines, formatSearchView } from './tool-view.ts'
|
|
35
|
+
import { TODO_TOOL_NAME } from './todo-view.ts'
|
|
36
|
+
import { ASK_USER_TOOL_NAME } from './question-view.ts'
|
|
37
|
+
import type { DiffLike, ReadLineLike, ToolDiffView } from './tool-view.ts'
|
|
38
|
+
import type { ToolResultPresentation, ToolReadView } from '../contract/index.ts'
|
|
39
|
+
import { ToolCallLedger, createToolViewPresenter, toolViewOf } from './tool-views.ts'
|
|
40
|
+
import { computeSessionList } from './session-list.ts'
|
|
41
|
+
import type { SessionSummary } from './session-list.ts'
|
|
42
|
+
import { builtInPresetName, isBlankSession, presetDisplayName } from './presets.ts'
|
|
43
|
+
import type { PresetSummary } from './presets.ts'
|
|
44
|
+
import type { EffortSummary } from './efforts.ts'
|
|
45
|
+
import { InteractionStore, registerInteractionChannels } from './interactions.ts'
|
|
46
|
+
import { applyTheme } from '../apply-theme.ts'
|
|
47
|
+
import { THEME_SETTINGS_NAMESPACE } from '../theme-settings.ts'
|
|
48
|
+
import { themeMode } from '../theme.ts'
|
|
49
|
+
import type { ThemeMode } from '../theme.ts'
|
|
50
|
+
import { warn } from '../log.ts'
|
|
51
|
+
|
|
52
|
+
interface AttachmentsServiceLike {
|
|
53
|
+
saveImage(input: { data: Uint8Array; mediaType: string; name?: string }): Promise<ImageAttachmentRef>
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function attachmentsService(ctx: Context): AttachmentsServiceLike | undefined {
|
|
57
|
+
return ctx.get('attachments') as AttachmentsServiceLike | undefined
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function resolvePendingImages(images: readonly PendingImage[]): Promise<{ ok: ImageAttachmentInput[]; failed: string[] }> {
|
|
61
|
+
const ok: ImageAttachmentInput[] = []
|
|
62
|
+
const failed: string[] = []
|
|
63
|
+
for (const image of images) {
|
|
64
|
+
if (image.kind === 'data') {
|
|
65
|
+
ok.push({ name: image.name ?? 'clipboard', mediaType: image.mediaType, data: image.data })
|
|
66
|
+
continue
|
|
67
|
+
}
|
|
68
|
+
const mediaType = imageMediaTypeOf(image.path)
|
|
69
|
+
if (mediaType === undefined) {
|
|
70
|
+
failed.push(image.path)
|
|
71
|
+
continue
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
const { readFile } = await import('node:fs/promises')
|
|
75
|
+
ok.push({ name: image.path, mediaType, data: new Uint8Array(await readFile(image.path)) })
|
|
76
|
+
} catch {
|
|
77
|
+
failed.push(image.path)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return { ok, failed }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface ImageAttachmentInput {
|
|
84
|
+
name: string
|
|
85
|
+
mediaType: string
|
|
86
|
+
data: Uint8Array
|
|
87
|
+
}
|
|
88
|
+
interface AgentLike {
|
|
89
|
+
followup(message: unknown): void
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const EMPTY_TEXT = { type: 'text' as const, text: '' }
|
|
93
|
+
|
|
94
|
+
async function sendContent(
|
|
95
|
+
agent: AgentLike,
|
|
96
|
+
text: string,
|
|
97
|
+
hasText: boolean,
|
|
98
|
+
groups: ReadonlyArray<readonly PendingImage[]>,
|
|
99
|
+
ctx: Context,
|
|
100
|
+
): Promise<void> {
|
|
101
|
+
if (groups.length === 0) {
|
|
102
|
+
agent.followup(createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } }))
|
|
103
|
+
return
|
|
104
|
+
}
|
|
105
|
+
const attachments = attachmentsService(ctx)
|
|
106
|
+
if (attachments === undefined) {
|
|
107
|
+
const note = 'image attachments require the attachment service, which is not mounted'
|
|
108
|
+
agent.followup(createUserMessage({
|
|
109
|
+
content: [{ type: 'text', text: hasText ? `${text}\n\n${note}` : note }],
|
|
110
|
+
source: { kind: 'user' },
|
|
111
|
+
}))
|
|
112
|
+
return
|
|
113
|
+
}
|
|
114
|
+
const content: ContentBlock[] = hasText ? [{ type: 'text', text }] : []
|
|
115
|
+
for (const group of groups) {
|
|
116
|
+
const { ok, failed } = await resolvePendingImages(group)
|
|
117
|
+
if (failed.length > 0) {
|
|
118
|
+
content.push({ type: 'text', text: `[image failed: ${failed.join(', ')}]` })
|
|
119
|
+
}
|
|
120
|
+
for (const image of ok) {
|
|
121
|
+
try {
|
|
122
|
+
const ref = await attachments.saveImage({ data: image.data, mediaType: image.mediaType, name: image.name })
|
|
123
|
+
content.push({ type: 'image', attachment: ref })
|
|
124
|
+
} catch {
|
|
125
|
+
content.push({ type: 'text', text: `[image failed: ${image.name}]` })
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
content.push({ ...EMPTY_TEXT })
|
|
129
|
+
}
|
|
130
|
+
const hasImage = content.some(block => block.type === 'image')
|
|
131
|
+
if (!hasImage) {
|
|
132
|
+
const joined = content.map(block => block.type === 'text' ? block.text : '').join('').trim()
|
|
133
|
+
agent.followup(createUserMessage({ content: [{ type: 'text', text: joined }], source: { kind: 'user' } }))
|
|
134
|
+
return
|
|
135
|
+
}
|
|
136
|
+
agent.followup(createUserMessage({ content, source: { kind: 'user' } }))
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export const PERMISSION_PRESETS = ['workspace-write', 'danger-full-access', 'read-only'] as const
|
|
140
|
+
|
|
141
|
+
interface PermissionPresetsLike {
|
|
142
|
+
current(events: readonly SessionEvent[]): string
|
|
143
|
+
set(session: Session, name: string): void
|
|
144
|
+
readonly names: readonly string[]
|
|
145
|
+
readonly defaultPreset: string
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export interface TokenStats {
|
|
149
|
+
input: number
|
|
150
|
+
output: number
|
|
151
|
+
hitPercent: number
|
|
152
|
+
contextPercent: number
|
|
153
|
+
projectedTokens?: number
|
|
154
|
+
contextWindow?: number
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export interface ToolResultLike {
|
|
158
|
+
content: readonly ContentBlock[]
|
|
159
|
+
isError: boolean
|
|
160
|
+
meta?: unknown
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export interface ToolCallPresentation {
|
|
164
|
+
label: string
|
|
165
|
+
body: string
|
|
166
|
+
bodyCol?: number
|
|
167
|
+
diff?: ToolDiffView
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export interface ChatToolPresenter {
|
|
171
|
+
call(name: string, callId: string, argumentsRaw: string): ToolCallPresentation | undefined
|
|
172
|
+
result(callId: string, result: ToolResultLike): ToolResultPresentation | undefined
|
|
173
|
+
argsJson(callId: string): string | undefined
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export interface CallViewLike {
|
|
177
|
+
card: string
|
|
178
|
+
title?: string
|
|
179
|
+
kind?: string
|
|
180
|
+
rawInput?: unknown
|
|
181
|
+
description?: string
|
|
182
|
+
cwd?: string
|
|
183
|
+
content?: readonly ContentBlock[]
|
|
184
|
+
diffs?: readonly DiffLike[]
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export interface ResultViewLike {
|
|
188
|
+
card: string
|
|
189
|
+
output?: string
|
|
190
|
+
exitCode?: number
|
|
191
|
+
signal?: string
|
|
192
|
+
content?: readonly ContentBlock[]
|
|
193
|
+
diffs?: readonly DiffLike[]
|
|
194
|
+
lines?: readonly ReadLineLike[]
|
|
195
|
+
shape?: 'paths' | 'matches'
|
|
196
|
+
paths?: readonly string[]
|
|
197
|
+
files?: readonly { path: string; matches: readonly { lineNumber: number; line: string }[] }[]
|
|
198
|
+
truncated?: boolean
|
|
199
|
+
total?: number
|
|
200
|
+
kind?: string
|
|
201
|
+
url?: string
|
|
202
|
+
statusCode?: number
|
|
203
|
+
sources?: readonly { url: string; title?: string; snippet?: string; publishedAt?: string }[]
|
|
204
|
+
answer?: string
|
|
205
|
+
title?: string
|
|
206
|
+
path?: string
|
|
207
|
+
offset?: number
|
|
208
|
+
totalLines?: number
|
|
209
|
+
lang?: string
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
interface ToolsLike {
|
|
213
|
+
get?(name: string, scope?: unknown): {
|
|
214
|
+
presentCall?(args: unknown): CallViewLike | undefined
|
|
215
|
+
presentResult?(args: unknown, result: ToolResultLike): ResultViewLike | undefined
|
|
216
|
+
} | undefined
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export interface BuiltinToolPresenterDeps {
|
|
220
|
+
/** The shared call ledger; a call a contribution took over still resolves here. */
|
|
221
|
+
ledger: ToolCallLedger
|
|
222
|
+
presentCall(tool: string, args: unknown): CallViewLike | undefined
|
|
223
|
+
presentResult(tool: string, args: unknown, result: ToolResultLike): ResultViewLike | undefined
|
|
224
|
+
cwd(): string
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function parseArgumentsRaw(argumentsRaw: string): unknown {
|
|
228
|
+
try {
|
|
229
|
+
return JSON.parse(argumentsRaw) as unknown
|
|
230
|
+
} catch {
|
|
231
|
+
return argumentsRaw
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function resultTitleLabel(name: string, title: string | undefined): string | undefined {
|
|
236
|
+
if (title === undefined || title === '') return undefined
|
|
237
|
+
const suffix = dedupeTitle(name, title)
|
|
238
|
+
return suffix === '' ? name : `${name} ${suffix}`
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* The builtin presenter: maps the harness render-intent protocol
|
|
243
|
+
* (`presentCall`/`presentResult` views) onto the universal tool bubble with
|
|
244
|
+
* no per-tool branching. Tools the protocol does not describe fall through to
|
|
245
|
+
* the generic summary (priority-key primary param + args JSON body).
|
|
246
|
+
*/
|
|
247
|
+
export function createBuiltinToolPresenter(deps: BuiltinToolPresenterDeps): ChatToolPresenter {
|
|
248
|
+
const cwd = (): string => deps.cwd()
|
|
249
|
+
return {
|
|
250
|
+
call(name, callId, argumentsRaw) {
|
|
251
|
+
const remembered = deps.ledger.get(callId)
|
|
252
|
+
const args = remembered !== undefined ? remembered.args : parseArgumentsRaw(argumentsRaw)
|
|
253
|
+
const view = deps.presentCall(name, args)
|
|
254
|
+
if (view !== undefined && view.card === 'diff' && view.diffs !== undefined && view.diffs.length > 0) {
|
|
255
|
+
const rawPath = view.diffs[0]!.path
|
|
256
|
+
const path = pathFromTitle(view.title, typeof rawPath === 'string' && rawPath !== '' ? rawPath
|
|
257
|
+
: String((args as { file_path?: unknown }).file_path ?? (args as { path?: unknown }).path ?? ''))
|
|
258
|
+
const extra = view.diffs.length > 1 ? ` (+${view.diffs.length - 1})` : ''
|
|
259
|
+
return {
|
|
260
|
+
label: `${name} ${truncateSummary(relativize(path, cwd()))}${extra}`,
|
|
261
|
+
body: '',
|
|
262
|
+
diff: { path: relativize(path, cwd()), hunks: diffLineGroups(view.diffs) },
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
if (view !== undefined && view.card === 'terminal') {
|
|
266
|
+
return {
|
|
267
|
+
label: view.description === undefined || view.description === '' ? name : `${name} ${view.description}`,
|
|
268
|
+
body: terminalCallBody(view.title, view.cwd, cwd()),
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
if (view !== undefined && view.kind === 'read') {
|
|
272
|
+
const rawPath = String((args as { file_path?: unknown }).file_path ?? (args as { path?: unknown }).path ?? '')
|
|
273
|
+
if (rawPath !== '') {
|
|
274
|
+
const offset = (args as { offset?: unknown }).offset
|
|
275
|
+
const limit = (args as { limit?: unknown }).limit
|
|
276
|
+
const window = [
|
|
277
|
+
...(typeof offset === 'number' && offset > 0 ? [`offset=${offset}`] : []),
|
|
278
|
+
...(typeof limit === 'number' && limit > 0 ? [`lines=${limit}`] : []),
|
|
279
|
+
]
|
|
280
|
+
const label = `${name} ${truncateSummary(relativize(rawPath, cwd()))}${window.length === 0 ? '' : `[${window.join(',')}]`}`
|
|
281
|
+
return { label, body: '' }
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
if (view !== undefined && (view.kind === 'search' || view.kind === 'fetch')) {
|
|
285
|
+
const primary = dedupeTitle(name, view.title ?? '')
|
|
286
|
+
return { label: primary === '' ? name : `${name} ${primary}`, body: '' }
|
|
287
|
+
}
|
|
288
|
+
if (view !== undefined) {
|
|
289
|
+
const contentText = view.content !== undefined ? textFromBlocks(view.content) : ''
|
|
290
|
+
const suffix = genericCallHeader(name, view, contentText)
|
|
291
|
+
const label = suffix === '' ? name : `${name} ${suffix}`
|
|
292
|
+
return { label, body: genericCallBody(view.rawInput, contentText, suffix) }
|
|
293
|
+
}
|
|
294
|
+
const primary = pickPrimaryParam(args)
|
|
295
|
+
return {
|
|
296
|
+
label: primary === undefined ? name : `${name} ${primary.key}=${primary.value}`,
|
|
297
|
+
body: remainingArgsJson(args, primary?.key),
|
|
298
|
+
}
|
|
299
|
+
},
|
|
300
|
+
result(callId, result) {
|
|
301
|
+
const call = deps.ledger.get(callId)
|
|
302
|
+
if (call === undefined) return undefined
|
|
303
|
+
const name = call.name
|
|
304
|
+
const view = deps.presentResult(name, call.args, result)
|
|
305
|
+
if (view === undefined) return undefined
|
|
306
|
+
const label = resultTitleLabel(name, view.title)
|
|
307
|
+
const labelPatch = label === undefined ? {} : { label }
|
|
308
|
+
if (view.card === 'terminal') {
|
|
309
|
+
return {
|
|
310
|
+
kind: 'append',
|
|
311
|
+
text: view.output ?? '',
|
|
312
|
+
...view.exitCode === undefined ? {} : { exitCode: view.exitCode },
|
|
313
|
+
...view.signal === undefined ? {} : { signal: view.signal },
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
if (view.card === 'read' && view.lines !== undefined) {
|
|
317
|
+
if (view.lines.length === 0 && view.totalLines === undefined) return undefined
|
|
318
|
+
const read: ToolReadView = {
|
|
319
|
+
...(view.path === undefined || view.path === '' ? {} : { path: relativize(view.path, cwd()) }),
|
|
320
|
+
lines: view.lines,
|
|
321
|
+
...(view.offset === undefined || view.offset <= 0 ? {} : { offset: view.offset }),
|
|
322
|
+
...(view.totalLines === undefined ? {} : { totalLines: view.totalLines }),
|
|
323
|
+
...(view.lang === undefined || view.lang === '' ? {} : { lang: view.lang }),
|
|
324
|
+
}
|
|
325
|
+
return { kind: 'replace', text: '', ...labelPatch, read }
|
|
326
|
+
}
|
|
327
|
+
if (view.card === 'diff' && view.diffs !== undefined) {
|
|
328
|
+
const rawPath = view.diffs[0]!.path
|
|
329
|
+
const path = typeof rawPath === 'string' && rawPath !== '' ? rawPath : ''
|
|
330
|
+
return {
|
|
331
|
+
kind: 'replace',
|
|
332
|
+
text: formatDiffDiffs(view.diffs),
|
|
333
|
+
bodyCol: 2,
|
|
334
|
+
...labelPatch,
|
|
335
|
+
diff: { path: relativize(path, cwd()), hunks: diffLineGroups(view.diffs) },
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
if (view.card === 'generic' && (view.content !== undefined || label !== undefined)) {
|
|
339
|
+
return { kind: 'append', text: view.content === undefined ? '' : textFromBlocks(view.content), ...labelPatch }
|
|
340
|
+
}
|
|
341
|
+
if (view.card === 'search') {
|
|
342
|
+
const grouped = formatSearchView(view)
|
|
343
|
+
// An empty structured result (no paths, no matches) still has a raw
|
|
344
|
+
// text — "No matches found" — that the card must not blank out.
|
|
345
|
+
if (grouped === '' && view.truncated !== true) {
|
|
346
|
+
const raw = textFromBlocks(result.content)
|
|
347
|
+
if (raw.trim() !== '') return { kind: 'replace', text: raw, ...labelPatch }
|
|
348
|
+
}
|
|
349
|
+
const lines: string[] = []
|
|
350
|
+
if (grouped !== '') lines.push(grouped)
|
|
351
|
+
if (view.truncated === true) {
|
|
352
|
+
lines.push(`... ${view.total} total`)
|
|
353
|
+
lines.push(...recoveryLines(textFromBlocks(result.content)))
|
|
354
|
+
}
|
|
355
|
+
return { kind: 'replace', text: lines.length === 0 ? '' : lines.join('\n'), ...labelPatch }
|
|
356
|
+
}
|
|
357
|
+
if (view.card === 'web' && view.kind === 'search') {
|
|
358
|
+
const lines: string[] = []
|
|
359
|
+
if (view.answer !== undefined && view.answer !== '') lines.push(view.answer)
|
|
360
|
+
for (const source of view.sources ?? []) {
|
|
361
|
+
const date = source.publishedAt === undefined || source.publishedAt === ''
|
|
362
|
+
? ''
|
|
363
|
+
: ` (${source.publishedAt.slice(0, 10)})`
|
|
364
|
+
lines.push(`- ${source.title === undefined ? source.url : `${source.title} · ${source.url}`}${date}`)
|
|
365
|
+
if (source.snippet !== undefined && source.snippet !== '') {
|
|
366
|
+
lines.push(` ${truncateSummary(source.snippet, 160)}`)
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
if (view.truncated === true) lines.push('... results truncated')
|
|
370
|
+
return { kind: 'replace', text: lines.join('\n') }
|
|
371
|
+
}
|
|
372
|
+
if (view.card === 'web' && view.kind === 'fetch') {
|
|
373
|
+
const header = `HTTP ${view.statusCode} ${view.url}`
|
|
374
|
+
const body = textFromBlocks(result.content)
|
|
375
|
+
return { kind: 'replace', text: `${header}${view.truncated === true ? ' · ... content truncated' : ''}${body === '' ? '' : `\n\n${body}`}` }
|
|
376
|
+
}
|
|
377
|
+
return undefined
|
|
378
|
+
},
|
|
379
|
+
argsJson: callId => deps.ledger.argsJson(callId),
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
export interface RegistryCommand {
|
|
384
|
+
name: string
|
|
385
|
+
description: string
|
|
386
|
+
hint?: string
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
interface CommandsServiceLike {
|
|
390
|
+
list(agent: unknown): readonly { name: string; description: string; input?: { hint: string } }[]
|
|
391
|
+
execute(agent: unknown, line: string, images: readonly unknown[], signal: AbortSignal): Promise<unknown>
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
export interface ChatBridge {
|
|
395
|
+
modelName(): string
|
|
396
|
+
send(text: string, images?: ReadonlyArray<readonly PendingImage[]>): void
|
|
397
|
+
interrupt(): void
|
|
398
|
+
subscribe(handler: (event: SessionEvent) => void): () => void
|
|
399
|
+
listSessions(): Promise<SessionSummary[]>
|
|
400
|
+
onSessionListChanged(listener: () => void): () => void
|
|
401
|
+
openSession(id: string): Promise<void>
|
|
402
|
+
newSession(): Promise<void>
|
|
403
|
+
archiveSession(id: string): Promise<void>
|
|
404
|
+
activeSessionId(): string
|
|
405
|
+
listModels(): Promise<ConfiguredModel[]>
|
|
406
|
+
selectModel(provider: string, model: string): Promise<void>
|
|
407
|
+
fetchCustomModels(form: CustomProviderForm): Promise<LlmDiscoveredModel[]>
|
|
408
|
+
saveCustomProvider(form: CustomProviderForm, models: LlmDiscoveredModel[]): Promise<void>
|
|
409
|
+
listProviderDirectory(): Promise<OfficialProvider[]>
|
|
410
|
+
fetchProviderModels(provider: OfficialProvider, apiKey: string): Promise<LlmDiscoveredModel[] | undefined>
|
|
411
|
+
saveProviderKey(provider: OfficialProvider, apiKey: string): Promise<void>
|
|
412
|
+
saveProviderModels(provider: OfficialProvider, models: LlmDiscoveredModel[]): Promise<void>
|
|
413
|
+
deleteProvider(provider: OfficialProvider): Promise<void>
|
|
414
|
+
readModelEntries(ns: string, provider: string): ModelEntryConfig[]
|
|
415
|
+
saveModelEntry(ns: string, provider: string, entry: ModelEntryConfig): Promise<void>
|
|
416
|
+
deleteModelEntry(ns: string, provider: string, modelId: string): Promise<void>
|
|
417
|
+
describeModel(provider: string, model: string): Promise<DescribedModel>
|
|
418
|
+
cwd(): string
|
|
419
|
+
listPresets(): Promise<PresetSummary[]>
|
|
420
|
+
currentPreset(): string
|
|
421
|
+
presetName(): string
|
|
422
|
+
selectPreset(id: string): Promise<void>
|
|
423
|
+
listEfforts(): Promise<EffortSummary[]>
|
|
424
|
+
currentEffort(): string | undefined
|
|
425
|
+
effortName(): string | undefined
|
|
426
|
+
selectEffort(id: string): Promise<void>
|
|
427
|
+
permissionMode(): string
|
|
428
|
+
cyclePermission(): void
|
|
429
|
+
listPermissionPresets(): Promise<string[]>
|
|
430
|
+
defaultPermission(): string
|
|
431
|
+
setDefaultPermission(id: string): Promise<void>
|
|
432
|
+
defaultPresetId(): string
|
|
433
|
+
setDefaultPreset(id: string): Promise<void>
|
|
434
|
+
themePreference(): ThemeMode
|
|
435
|
+
setThemePreference(mode: ThemeMode): Promise<void>
|
|
436
|
+
tokenStats(): TokenStats
|
|
437
|
+
toolPresenter: ChatToolPresenter
|
|
438
|
+
interactions: InteractionStore
|
|
439
|
+
listRegistryCommands(): readonly RegistryCommand[]
|
|
440
|
+
onRegistryChanged(listener: () => void): () => void
|
|
441
|
+
executeCommandLine(line: string): Promise<void>
|
|
442
|
+
dispose(): void
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
let sessionCounter = 0
|
|
446
|
+
|
|
447
|
+
const SESSION_LIST_TTL_MS = 2000
|
|
448
|
+
const EFFORT_LIST_TTL_MS = 30000
|
|
449
|
+
const PRESET_LIST_TTL_MS = 2000
|
|
450
|
+
const MODEL_LIST_TTL_MS = 30000
|
|
451
|
+
|
|
452
|
+
interface CachedList<T> {
|
|
453
|
+
get(force?: boolean): Promise<T[]>
|
|
454
|
+
invalidate(): void
|
|
455
|
+
clear(): void
|
|
456
|
+
onRefresh(listener: () => void): () => void
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const INVALIDATE_REFRESH_DELAY_MS = 100
|
|
460
|
+
|
|
461
|
+
function cachedList<T>(
|
|
462
|
+
load: () => Promise<T[]>,
|
|
463
|
+
ttlMs: number,
|
|
464
|
+
options: { staleWhileRevalidate?: boolean; refreshOnInvalidate?: boolean } = {},
|
|
465
|
+
): CachedList<T> {
|
|
466
|
+
let cache: T[] | undefined
|
|
467
|
+
let refresh: Promise<T[]> | undefined
|
|
468
|
+
let refreshedAt = 0
|
|
469
|
+
let invalidations = 0
|
|
470
|
+
let invalidateTimer: ReturnType<typeof setTimeout> | undefined
|
|
471
|
+
const listeners = new Set<() => void>()
|
|
472
|
+
const startRefresh = (): Promise<T[]> => {
|
|
473
|
+
if (refresh !== undefined) return refresh
|
|
474
|
+
const epoch = invalidations
|
|
475
|
+
refresh = load()
|
|
476
|
+
.then(records => {
|
|
477
|
+
cache = records
|
|
478
|
+
if (epoch === invalidations) refreshedAt = Date.now()
|
|
479
|
+
for (const listener of [...listeners]) listener()
|
|
480
|
+
return records
|
|
481
|
+
})
|
|
482
|
+
.finally(() => {
|
|
483
|
+
refresh = undefined
|
|
484
|
+
})
|
|
485
|
+
return refresh
|
|
486
|
+
}
|
|
487
|
+
const scheduleRefresh = (): void => {
|
|
488
|
+
if (invalidateTimer !== undefined) return
|
|
489
|
+
invalidateTimer = setTimeout(() => {
|
|
490
|
+
invalidateTimer = undefined
|
|
491
|
+
void startRefresh().catch(() => {})
|
|
492
|
+
}, INVALIDATE_REFRESH_DELAY_MS)
|
|
493
|
+
}
|
|
494
|
+
return {
|
|
495
|
+
get(force = false) {
|
|
496
|
+
const now = Date.now()
|
|
497
|
+
if (!force && cache !== undefined && refreshedAt > now - ttlMs) return Promise.resolve(cache)
|
|
498
|
+
if (!force && options.staleWhileRevalidate === true && cache !== undefined) {
|
|
499
|
+
void startRefresh().catch(() => {})
|
|
500
|
+
return Promise.resolve(cache)
|
|
501
|
+
}
|
|
502
|
+
return startRefresh()
|
|
503
|
+
},
|
|
504
|
+
invalidate() {
|
|
505
|
+
refreshedAt = 0
|
|
506
|
+
invalidations += 1
|
|
507
|
+
if (options.refreshOnInvalidate === true) scheduleRefresh()
|
|
508
|
+
},
|
|
509
|
+
clear() {
|
|
510
|
+
cache = undefined
|
|
511
|
+
refreshedAt = 0
|
|
512
|
+
},
|
|
513
|
+
onRefresh(listener) {
|
|
514
|
+
listeners.add(listener)
|
|
515
|
+
return () => {
|
|
516
|
+
listeners.delete(listener)
|
|
517
|
+
}
|
|
518
|
+
},
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
export async function createChatBridge(ctx: Context): Promise<ChatBridge> {
|
|
523
|
+
const interactions = new InteractionStore()
|
|
524
|
+
let interactionChannels: (() => void) | undefined
|
|
525
|
+
try {
|
|
526
|
+
interactionChannels = registerInteractionChannels(ctx as never, interactions)
|
|
527
|
+
} catch (cause) {
|
|
528
|
+
// Without the interaction channels, approvals and questions stay unanswered.
|
|
529
|
+
warn('bridge', 'interaction channels are unavailable', {
|
|
530
|
+
message: cause instanceof Error ? cause.message : String(cause),
|
|
531
|
+
})
|
|
532
|
+
}
|
|
533
|
+
const agentOptions = (): AgentOptions => {
|
|
534
|
+
const selection = ctx.get('agentDefaultModel')?.currentSelection()
|
|
535
|
+
if (selection === undefined) return {}
|
|
536
|
+
return { provider: selection.provider, model: selection.model }
|
|
537
|
+
}
|
|
538
|
+
const handlers = new Set<(event: SessionEvent) => void>()
|
|
539
|
+
const presets = ctx.get('agentPresets')
|
|
540
|
+
const selections = new WeakMap<Agent, ModelSelectionRef>()
|
|
541
|
+
const effortNames = new Map<string, string>()
|
|
542
|
+
const defaultCwd = process.cwd()
|
|
543
|
+
let currentCwd = defaultCwd
|
|
544
|
+
void registerWorkspace(ctx, defaultCwd)
|
|
545
|
+
const sessionList = cachedList(
|
|
546
|
+
() => computeSessionList(ctx),
|
|
547
|
+
SESSION_LIST_TTL_MS,
|
|
548
|
+
{ staleWhileRevalidate: true, refreshOnInvalidate: true },
|
|
549
|
+
)
|
|
550
|
+
void sessionList.get().catch(() => {})
|
|
551
|
+
let activeHandle: AgentHandle | undefined = await createAgent(currentCwd)
|
|
552
|
+
let activeAgent = activeHandle.agent
|
|
553
|
+
const commandsService = ctx.get('commands') as CommandsServiceLike | undefined
|
|
554
|
+
const registryListeners = new Set<() => void>()
|
|
555
|
+
let registryCommands: RegistryCommand[] = []
|
|
556
|
+
const syncRegistry = (): void => {
|
|
557
|
+
if (commandsService === undefined) return
|
|
558
|
+
try {
|
|
559
|
+
registryCommands = commandsService.list(activeAgent).map(entry => ({
|
|
560
|
+
name: entry.name,
|
|
561
|
+
description: entry.description,
|
|
562
|
+
...(entry.input?.hint === undefined ? {} : { hint: entry.input.hint }),
|
|
563
|
+
}))
|
|
564
|
+
} catch {
|
|
565
|
+
registryCommands = []
|
|
566
|
+
}
|
|
567
|
+
for (const listener of [...registryListeners]) listener()
|
|
568
|
+
}
|
|
569
|
+
try {
|
|
570
|
+
;(ctx as unknown as { on(name: string, listener: () => void): void }).on('commands/change', () => syncRegistry())
|
|
571
|
+
} catch (cause) {
|
|
572
|
+
// The commands service does not emit change events on this host; the sync stays one-shot.
|
|
573
|
+
warn('bridge', 'commands/change subscription failed', {
|
|
574
|
+
message: cause instanceof Error ? cause.message : String(cause),
|
|
575
|
+
})
|
|
576
|
+
}
|
|
577
|
+
syncRegistry()
|
|
578
|
+
const llm = ctx.llm
|
|
579
|
+
const efforts = cachedList(() => resolveEffortSummaries(currentSelection()), EFFORT_LIST_TTL_MS)
|
|
580
|
+
const presetsList = cachedList(
|
|
581
|
+
async () => {
|
|
582
|
+
if (presets === undefined) return []
|
|
583
|
+
return (await presets.list()).map(preset => ({ id: preset.id, name: presetDisplayName(preset) }))
|
|
584
|
+
},
|
|
585
|
+
PRESET_LIST_TTL_MS,
|
|
586
|
+
)
|
|
587
|
+
const modelsList = cachedList(() => listConfiguredModels(llm), MODEL_LIST_TTL_MS, { staleWhileRevalidate: true })
|
|
588
|
+
|
|
589
|
+
function emit(event: SessionEvent): void {
|
|
590
|
+
for (const handler of [...handlers]) handler(event)
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
const offSessionEvents = ctx.on('session/event', (session, event) => {
|
|
594
|
+
if (session.id !== activeAgent.id) return
|
|
595
|
+
const type = (event as { type?: string }).type
|
|
596
|
+
if (type === 'session/title' || type === 'turn/start') sessionList.invalidate()
|
|
597
|
+
emit(event)
|
|
598
|
+
})
|
|
599
|
+
|
|
600
|
+
function installSelection(agentCtx: Context): void {
|
|
601
|
+
const agent = agentCtx.agent
|
|
602
|
+
if (agent === undefined) return
|
|
603
|
+
selectionFor(agent)
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function selectionWithEffort(resolved: ModelSelection): ModelSelection {
|
|
607
|
+
return {
|
|
608
|
+
provider: resolved.provider,
|
|
609
|
+
model: resolved.model,
|
|
610
|
+
...resolved.reasoningEffort === undefined ? {} : { reasoningEffort: resolved.reasoningEffort },
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function selectionFor(agent: Agent): ModelSelectionRef {
|
|
615
|
+
const installed = selections.get(agent)
|
|
616
|
+
if (installed !== undefined) return installed
|
|
617
|
+
let picked: ModelSelection | undefined
|
|
618
|
+
const selection: ModelSelectionRef = {
|
|
619
|
+
get current(): ModelSelection {
|
|
620
|
+
if (picked !== undefined) return picked
|
|
621
|
+
const logged = agent.session.requestHeader()?.config
|
|
622
|
+
if (logged === undefined) return ctx.agentDefaultModel.currentSelection()
|
|
623
|
+
return selectionWithEffort({ provider: logged.provider, model: logged.model, reasoningEffort: logged.reasoningEffort })
|
|
624
|
+
},
|
|
625
|
+
set current(next: ModelSelection) {
|
|
626
|
+
picked = next
|
|
627
|
+
},
|
|
628
|
+
assembled: undefined,
|
|
629
|
+
}
|
|
630
|
+
installModelSelection(agent.ctx, selection)
|
|
631
|
+
selections.set(agent, selection)
|
|
632
|
+
return selection
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
function currentSelection(): ModelSelection | undefined {
|
|
636
|
+
return selectionFor(activeAgent).current ?? ctx.agentDefaultModel.currentSelection()
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
async function resolveEffortSummaries(selection: ModelSelection | undefined): Promise<EffortSummary[]> {
|
|
640
|
+
if (selection === undefined) return []
|
|
641
|
+
try {
|
|
642
|
+
const info = await llm.resolveModelInfo(selection.provider, selection.model)
|
|
643
|
+
const base = `${selection.provider}/${selection.model}`
|
|
644
|
+
for (const effort of info.reasoning?.efforts ?? []) {
|
|
645
|
+
effortNames.set(`${base}/${effort.id}`, effort.name)
|
|
646
|
+
}
|
|
647
|
+
return (info.reasoning?.efforts ?? []).map(effort => ({ id: effort.id, name: effort.name }))
|
|
648
|
+
} catch {
|
|
649
|
+
return []
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
async function refreshEffortNames(): Promise<void> {
|
|
654
|
+
await resolveEffortSummaries(currentSelection())
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
async function describeModel(provider: string, model: string): Promise<DescribedModel> {
|
|
658
|
+
const info = await llm.resolveModelInfo(provider, model)
|
|
659
|
+
return {
|
|
660
|
+
name: info.name,
|
|
661
|
+
...(info.context === undefined ? {} : { contextWindow: info.context.contextWindow }),
|
|
662
|
+
image: info.inputModalities?.includes('image') ?? false,
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
async function createAgent(cwd: string, presetId?: string): Promise<AgentHandle> {
|
|
667
|
+
const resolved = presetId ?? (await presets?.resolve())?.id
|
|
668
|
+
const meta = { cwd, ...(resolved === undefined ? {} : { agentPreset: resolved }) }
|
|
669
|
+
const setup = async (agentCtx: Context) => {
|
|
670
|
+
installSelection(agentCtx)
|
|
671
|
+
await presets?.mount(agentCtx, resolved)
|
|
672
|
+
}
|
|
673
|
+
if (typeof ctx.agentLoop.createAgent === 'function') {
|
|
674
|
+
return ctx.agentLoop.createAgent(ctx, {
|
|
675
|
+
sessionId: SessionId(`tui-${Date.now()}-${++sessionCounter}`),
|
|
676
|
+
meta,
|
|
677
|
+
agentOptions: agentOptions(),
|
|
678
|
+
setup,
|
|
679
|
+
})
|
|
680
|
+
}
|
|
681
|
+
const agent = ctx.agentLoop.create(
|
|
682
|
+
SessionId(`tui-${Date.now()}-${++sessionCounter}`),
|
|
683
|
+
agentOptions(),
|
|
684
|
+
{ cwd },
|
|
685
|
+
)
|
|
686
|
+
installSelection(agent.ctx)
|
|
687
|
+
await presets?.mount(agent.ctx, resolved)
|
|
688
|
+
return {
|
|
689
|
+
agent,
|
|
690
|
+
dispose: async () => {
|
|
691
|
+
agent.cancel({ kind: 'disposed' })
|
|
692
|
+
await agent.whenIdle()
|
|
693
|
+
},
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
async function resumeAgent(id: string): Promise<AgentHandle> {
|
|
698
|
+
const query = ctx.get('sessionQuery') as {
|
|
699
|
+
readSession?(id: string): Promise<{ session: { cwd?: string }; events: SessionEvent[] }>
|
|
700
|
+
} | undefined
|
|
701
|
+
const setup = async (agentCtx: Context) => {
|
|
702
|
+
installSelection(agentCtx)
|
|
703
|
+
const session = agentCtx.agent?.session
|
|
704
|
+
const resolved = session === undefined ? undefined : resolveSessionPreset(session)
|
|
705
|
+
await presets?.mount(agentCtx, resolved)
|
|
706
|
+
}
|
|
707
|
+
const spawnResumedAgent = async (snapshot: { session: { cwd?: string }; events: SessionEvent[] }): Promise<AgentHandle> => {
|
|
708
|
+
return ctx.agentLoop.createAgent(ctx, {
|
|
709
|
+
sessionId: SessionId(id),
|
|
710
|
+
seed: snapshot.events,
|
|
711
|
+
meta: { cwd: snapshot.session.cwd },
|
|
712
|
+
agentOptions: agentOptions(),
|
|
713
|
+
setup,
|
|
714
|
+
})
|
|
715
|
+
}
|
|
716
|
+
if (typeof ctx.agentLoop.resume === 'function') {
|
|
717
|
+
try {
|
|
718
|
+
return await ctx.agentLoop.resume(ctx, { resumeSessionId: SessionId(id), agentOptions: agentOptions(), setup })
|
|
719
|
+
} catch (error) {
|
|
720
|
+
if (query?.readSession === undefined) throw error
|
|
721
|
+
return spawnResumedAgent(await query.readSession(String(id)))
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
if (query?.readSession !== undefined) return spawnResumedAgent(await query.readSession(String(id)))
|
|
725
|
+
throw new Error('cannot resume session: session persistence is not configured')
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
async function activateAgent(handle: AgentHandle | undefined, agent: Agent): Promise<void> {
|
|
729
|
+
const previous = activeHandle
|
|
730
|
+
activeHandle = handle
|
|
731
|
+
activeAgent = agent
|
|
732
|
+
syncRegistry()
|
|
733
|
+
if (previous !== undefined) await previous.dispose()
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
function refreshAgentState(): void {
|
|
737
|
+
void repairEffortSelection()
|
|
738
|
+
sessionList.invalidate()
|
|
739
|
+
efforts.invalidate()
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
async function openSession(id: string): Promise<void> {
|
|
743
|
+
const sessionId = SessionId(id)
|
|
744
|
+
if (activeAgent.id === sessionId) {
|
|
745
|
+
for (const event of activeAgent.session.events) emit(event)
|
|
746
|
+
return
|
|
747
|
+
}
|
|
748
|
+
const existing = ctx.agents.get(sessionId)
|
|
749
|
+
if (existing !== undefined) {
|
|
750
|
+
await activateAgent(undefined, existing)
|
|
751
|
+
syncCwd()
|
|
752
|
+
for (const event of activeAgent.session.events) emit(event)
|
|
753
|
+
refreshAgentState()
|
|
754
|
+
return
|
|
755
|
+
}
|
|
756
|
+
const handle = await resumeAgent(sessionId)
|
|
757
|
+
await activateAgent(handle, handle.agent)
|
|
758
|
+
syncCwd()
|
|
759
|
+
for (const event of activeAgent.session.events) emit(event)
|
|
760
|
+
refreshAgentState()
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
function syncCwd(): void {
|
|
764
|
+
const cwd = activeAgent.session.header.cwd
|
|
765
|
+
if (cwd !== undefined) currentCwd = cwd
|
|
766
|
+
void registerWorkspace(ctx, currentCwd)
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
async function newSession(): Promise<void> {
|
|
770
|
+
currentCwd = defaultCwd
|
|
771
|
+
void registerWorkspace(ctx, currentCwd)
|
|
772
|
+
const handle = await createAgent(currentCwd)
|
|
773
|
+
await activateAgent(handle, handle.agent)
|
|
774
|
+
refreshAgentState()
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
async function archiveSession(id: string): Promise<void> {
|
|
778
|
+
const workspace = ctx.get('workspaceRegistry') as { archiveSession?(sessionId: SessionId): Promise<void> } | undefined
|
|
779
|
+
if (workspace?.archiveSession === undefined) throw new Error('workspace registry is not configured')
|
|
780
|
+
await workspace.archiveSession(SessionId(id))
|
|
781
|
+
sessionList.invalidate()
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
async function repairEffortSelection(): Promise<void> {
|
|
785
|
+
const selection = currentSelection()
|
|
786
|
+
if (selection === undefined || selection.reasoningEffort === undefined) return
|
|
787
|
+
try {
|
|
788
|
+
await llm.resolveCallConfig({
|
|
789
|
+
provider: selection.provider,
|
|
790
|
+
model: selection.model,
|
|
791
|
+
reasoningEffort: selection.reasoningEffort,
|
|
792
|
+
})
|
|
793
|
+
efforts.invalidate()
|
|
794
|
+
return
|
|
795
|
+
} catch {
|
|
796
|
+
// The configured effort is unsupported; fall through to the plain call config below.
|
|
797
|
+
}
|
|
798
|
+
try {
|
|
799
|
+
const resolved = await llm.resolveCallConfig({ provider: selection.provider, model: selection.model })
|
|
800
|
+
selectionFor(activeAgent).current = resolved
|
|
801
|
+
efforts.invalidate()
|
|
802
|
+
return
|
|
803
|
+
} catch {
|
|
804
|
+
// Even the plain config is unreachable; fall back to the default model selection.
|
|
805
|
+
}
|
|
806
|
+
const fallback = ctx.get('agentDefaultModel')?.currentSelection()
|
|
807
|
+
if (fallback !== undefined) selectionFor(activeAgent).current = fallback
|
|
808
|
+
efforts.invalidate()
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
function currentPreset(): string {
|
|
812
|
+
const resolved = resolveSessionPreset(activeAgent.session)
|
|
813
|
+
return resolved ?? presets?.defaultId ?? 'standard'
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
async function selectPreset(id: string): Promise<void> {
|
|
817
|
+
if (presets === undefined) throw new Error('agent presets are not configured')
|
|
818
|
+
if (currentPreset() === id) return
|
|
819
|
+
const session = activeAgent.session
|
|
820
|
+
if (!isBlankSession(session.events)) {
|
|
821
|
+
throw new Error('the preset is fixed once the session has started; use /new to start a new session')
|
|
822
|
+
}
|
|
823
|
+
const preset = await presets.recompose(activeAgent.ctx, id)
|
|
824
|
+
session.append('agent-preset/selected', { agentPreset: preset.id })
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
function permission(): PermissionPresetsLike | undefined {
|
|
828
|
+
return ctx.get('permissionPresets') as PermissionPresetsLike | undefined
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
function settingsService(): { update(ns: string, patch: object): Promise<void>; get(ns: string): unknown; mutate(ns: string, ops: readonly SettingsPathOp[]): Promise<void> } {
|
|
832
|
+
const service = ctx.get('settings') as { update(ns: string, patch: object): Promise<void>; get(ns: string): unknown; mutate(ns: string, ops: readonly SettingsPathOp[]): Promise<void> } | undefined
|
|
833
|
+
if (service === undefined) throw new Error('settings service is not available')
|
|
834
|
+
return service
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
const tools = ctx.get('tools') as ToolsLike | undefined
|
|
838
|
+
const ledger = new ToolCallLedger()
|
|
839
|
+
const toolPresenter = createToolViewPresenter(
|
|
840
|
+
createBuiltinToolPresenter({
|
|
841
|
+
ledger,
|
|
842
|
+
presentCall: (tool: string, args: unknown) => tools?.get?.(tool, activeAgent)?.presentCall?.(args),
|
|
843
|
+
presentResult: (tool: string, args: unknown, result: ToolResultLike) =>
|
|
844
|
+
tools?.get?.(tool, activeAgent)?.presentResult?.(args, result),
|
|
845
|
+
cwd: () => currentCwd,
|
|
846
|
+
}),
|
|
847
|
+
{ resolve: toolViewOf, cwd: () => currentCwd, ledger },
|
|
848
|
+
)
|
|
849
|
+
|
|
850
|
+
async function selectModel(provider: string, name: string): Promise<void> {
|
|
851
|
+
const resolved = await llm.resolveCallConfig({ provider, model: name })
|
|
852
|
+
selectionFor(activeAgent).current = selectionWithEffort(resolved)
|
|
853
|
+
try {
|
|
854
|
+
await ctx.agentDefaultModel.saveSelection(resolved)
|
|
855
|
+
} catch (cause) {
|
|
856
|
+
warn('model', 'failed to persist the default model selection', {
|
|
857
|
+
message: cause instanceof Error ? cause.message : String(cause),
|
|
858
|
+
})
|
|
859
|
+
}
|
|
860
|
+
efforts.invalidate()
|
|
861
|
+
void refreshEffortNames()
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
async function listEfforts(): Promise<EffortSummary[]> {
|
|
865
|
+
return efforts.get()
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
function currentEffort(): string | undefined {
|
|
869
|
+
return currentSelection()?.reasoningEffort
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
function effortName(): string | undefined {
|
|
873
|
+
const selection = currentSelection()
|
|
874
|
+
if (selection === undefined || selection.reasoningEffort === undefined) return undefined
|
|
875
|
+
return effortNames.get(`${selection.provider}/${selection.model}/${selection.reasoningEffort}`)
|
|
876
|
+
?? String(selection.reasoningEffort)
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
async function selectEffort(id: string): Promise<void> {
|
|
880
|
+
const current = currentSelection()
|
|
881
|
+
if (current === undefined || current.reasoningEffort === id) return
|
|
882
|
+
const resolved = await llm.resolveCallConfig({
|
|
883
|
+
provider: current.provider,
|
|
884
|
+
model: current.model,
|
|
885
|
+
reasoningEffort: ReasoningEffortId(id),
|
|
886
|
+
})
|
|
887
|
+
selectionFor(activeAgent).current = selectionWithEffort(resolved)
|
|
888
|
+
try {
|
|
889
|
+
await ctx.agentDefaultModel.saveSelection(resolved)
|
|
890
|
+
} catch (cause) {
|
|
891
|
+
warn('model', 'failed to persist the default effort selection', {
|
|
892
|
+
message: cause instanceof Error ? cause.message : String(cause),
|
|
893
|
+
})
|
|
894
|
+
}
|
|
895
|
+
efforts.invalidate()
|
|
896
|
+
void refreshEffortNames()
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
function tokenStats(): TokenStats {
|
|
900
|
+
const projections = ctx.get('sessionProjections') as {
|
|
901
|
+
snapshot?(session: Session): { values: Record<string, unknown> }
|
|
902
|
+
} | undefined
|
|
903
|
+
let usage: { uncachedInputTokens?: number; outputTokens?: number; cacheReadTokens?: number; cacheWriteTokens?: number } | undefined
|
|
904
|
+
let pressure: { projectedTokens?: number; contextWindow?: number } | undefined
|
|
905
|
+
if (projections !== undefined) {
|
|
906
|
+
try {
|
|
907
|
+
const values = projections.snapshot?.(activeAgent.session)?.values
|
|
908
|
+
usage = values?.tokenUsage as typeof usage
|
|
909
|
+
pressure = values?.contextPressure as typeof pressure
|
|
910
|
+
} catch {
|
|
911
|
+
// Projection snapshots may throw for sessions without token rows; stats fall back to zeros.
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
const input = (usage?.uncachedInputTokens ?? 0) + (usage?.cacheReadTokens ?? 0) + (usage?.cacheWriteTokens ?? 0)
|
|
915
|
+
const output = usage?.outputTokens ?? 0
|
|
916
|
+
const contextPercent = pressure?.projectedTokens !== undefined && pressure?.contextWindow !== undefined
|
|
917
|
+
? Math.min(100, Math.round(pressure.projectedTokens / pressure.contextWindow * 100))
|
|
918
|
+
: 0
|
|
919
|
+
return {
|
|
920
|
+
input,
|
|
921
|
+
output,
|
|
922
|
+
hitPercent: input === 0 ? 0 : Math.round((usage?.cacheReadTokens ?? 0) / input * 100),
|
|
923
|
+
contextPercent,
|
|
924
|
+
...pressure?.projectedTokens === undefined ? {} : { projectedTokens: pressure.projectedTokens },
|
|
925
|
+
...pressure?.contextWindow === undefined ? {} : { contextWindow: pressure.contextWindow },
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
async function listSessions(): Promise<SessionSummary[]> {
|
|
930
|
+
return sessionList.get()
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
void refreshEffortNames()
|
|
934
|
+
void modelsList.get().catch(() => {})
|
|
935
|
+
|
|
936
|
+
return {
|
|
937
|
+
modelName: () => currentSelection()?.model ?? '',
|
|
938
|
+
send(text: string, images?: ReadonlyArray<readonly PendingImage[]>) {
|
|
939
|
+
const hasText = text !== ''
|
|
940
|
+
sendContent(activeAgent, text, hasText, images ?? [], ctx)
|
|
941
|
+
},
|
|
942
|
+
interrupt() {
|
|
943
|
+
activeAgent.cancel({ kind: 'user' })
|
|
944
|
+
},
|
|
945
|
+
subscribe(handler: (event: SessionEvent) => void) {
|
|
946
|
+
handlers.add(handler)
|
|
947
|
+
return () => {
|
|
948
|
+
handlers.delete(handler)
|
|
949
|
+
}
|
|
950
|
+
},
|
|
951
|
+
listRegistryCommands: () => registryCommands,
|
|
952
|
+
onRegistryChanged(listener: () => void) {
|
|
953
|
+
registryListeners.add(listener)
|
|
954
|
+
return () => {
|
|
955
|
+
registryListeners.delete(listener)
|
|
956
|
+
}
|
|
957
|
+
},
|
|
958
|
+
async executeCommandLine(line: string) {
|
|
959
|
+
if (commandsService === undefined) return
|
|
960
|
+
const controller = new AbortController()
|
|
961
|
+
try {
|
|
962
|
+
await commandsService.execute(activeAgent, line, [], controller.signal)
|
|
963
|
+
} catch {
|
|
964
|
+
// Command failures already surface as session events; nothing further to report here.
|
|
965
|
+
}
|
|
966
|
+
},
|
|
967
|
+
listSessions,
|
|
968
|
+
onSessionListChanged: listener => sessionList.onRefresh(listener),
|
|
969
|
+
openSession,
|
|
970
|
+
newSession,
|
|
971
|
+
archiveSession,
|
|
972
|
+
activeSessionId: () => String(activeAgent.id),
|
|
973
|
+
listModels: () => modelsList.get(),
|
|
974
|
+
selectModel,
|
|
975
|
+
fetchCustomModels: form => fetchCustomModels(llm, form),
|
|
976
|
+
saveCustomProvider: async (form, models) => {
|
|
977
|
+
await saveCustomProvider(ctx.settings, ctx.credentials, form, models)
|
|
978
|
+
modelsList.clear()
|
|
979
|
+
},
|
|
980
|
+
listProviderDirectory: async () => listProviderDirectory(llm),
|
|
981
|
+
fetchProviderModels: async (provider, apiKey) => {
|
|
982
|
+
try {
|
|
983
|
+
return await fetchProviderModels(llm, provider.settingsNs, provider.provider, apiKey)
|
|
984
|
+
} catch (error) {
|
|
985
|
+
if ((error as { code?: string }).code === 'NO_DISCOVERY') return undefined
|
|
986
|
+
throw error
|
|
987
|
+
}
|
|
988
|
+
},
|
|
989
|
+
saveProviderKey: async (provider, apiKey) => {
|
|
990
|
+
await saveProviderKey(ctx.settings, ctx.credentials, provider, apiKey)
|
|
991
|
+
modelsList.clear()
|
|
992
|
+
},
|
|
993
|
+
saveProviderModels: async (provider, models) => {
|
|
994
|
+
await saveProviderModels(ctx.settings, provider, models)
|
|
995
|
+
modelsList.clear()
|
|
996
|
+
},
|
|
997
|
+
deleteProvider: async provider => {
|
|
998
|
+
const settings = settingsService()
|
|
999
|
+
await deleteProviderProfile(settings, ctx.credentials, provider)
|
|
1000
|
+
modelsList.clear()
|
|
1001
|
+
},
|
|
1002
|
+
readModelEntries: (ns, provider) => readModelEntries(settingsService(), ns, provider),
|
|
1003
|
+
describeModel,
|
|
1004
|
+
saveModelEntry: async (ns, provider, entry) => {
|
|
1005
|
+
const settings = settingsService()
|
|
1006
|
+
await saveModelEntry(settings, settings, ns, provider, entry)
|
|
1007
|
+
modelsList.clear()
|
|
1008
|
+
},
|
|
1009
|
+
deleteModelEntry: async (ns, provider, modelId) => {
|
|
1010
|
+
const settings = settingsService()
|
|
1011
|
+
await deleteModelEntry(settings, settings, ns, provider, modelId)
|
|
1012
|
+
modelsList.clear()
|
|
1013
|
+
},
|
|
1014
|
+
cwd: () => currentCwd,
|
|
1015
|
+
listPresets: () => presetsList.get(),
|
|
1016
|
+
currentPreset,
|
|
1017
|
+
presetName: () => builtInPresetName(currentPreset()) ?? currentPreset(),
|
|
1018
|
+
selectPreset,
|
|
1019
|
+
listEfforts,
|
|
1020
|
+
currentEffort,
|
|
1021
|
+
effortName,
|
|
1022
|
+
selectEffort,
|
|
1023
|
+
permissionMode: () => permission()?.current(activeAgent.session.events) ?? PERMISSION_PRESETS[0],
|
|
1024
|
+
cyclePermission: () => {
|
|
1025
|
+
const service = permission()
|
|
1026
|
+
if (service === undefined) return
|
|
1027
|
+
try {
|
|
1028
|
+
const current = service.current(activeAgent.session.events)
|
|
1029
|
+
const names = service.names.length > 0 ? service.names : PERMISSION_PRESETS
|
|
1030
|
+
const index = names.indexOf(current)
|
|
1031
|
+
const next = names[(index + 1) % names.length]
|
|
1032
|
+
service.set(activeAgent.session, next)
|
|
1033
|
+
} catch (error) {
|
|
1034
|
+
console.error(`dshtui: permission mode switch rejected: ${error instanceof Error ? error.message : String(error)}`)
|
|
1035
|
+
}
|
|
1036
|
+
},
|
|
1037
|
+
listPermissionPresets: async () => {
|
|
1038
|
+
const service = permission()
|
|
1039
|
+
return service === undefined ? [...PERMISSION_PRESETS] : [...service.names]
|
|
1040
|
+
},
|
|
1041
|
+
defaultPermission: () => permission()?.defaultPreset ?? PERMISSION_PRESETS[0],
|
|
1042
|
+
setDefaultPermission: async id => {
|
|
1043
|
+
await settingsService().update('permission', { defaultPreset: id })
|
|
1044
|
+
},
|
|
1045
|
+
defaultPresetId: () => presets?.defaultId ?? 'standard',
|
|
1046
|
+
setDefaultPreset: async id => {
|
|
1047
|
+
await settingsService().update('agent-presets', { default: id })
|
|
1048
|
+
},
|
|
1049
|
+
themePreference: () => themeMode(),
|
|
1050
|
+
setThemePreference: async mode => {
|
|
1051
|
+
applyTheme(mode)
|
|
1052
|
+
await settingsService().update(THEME_SETTINGS_NAMESPACE, { mode })
|
|
1053
|
+
},
|
|
1054
|
+
tokenStats,
|
|
1055
|
+
toolPresenter,
|
|
1056
|
+
interactions,
|
|
1057
|
+
dispose() {
|
|
1058
|
+
interactionChannels?.()
|
|
1059
|
+
interactions.dispose()
|
|
1060
|
+
offSessionEvents()
|
|
1061
|
+
},
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
async function registerWorkspace(ctx: Context, path: string): Promise<void> {
|
|
1066
|
+
const workspace = ctx.get('workspaceRegistry') as { create?(path: string): Promise<unknown> } | undefined
|
|
1067
|
+
try {
|
|
1068
|
+
await workspace?.create?.(path)
|
|
1069
|
+
} catch (cause) {
|
|
1070
|
+
// A session without a registered workspace still runs; the picker labels it ungrouped.
|
|
1071
|
+
warn('workspace', 'failed to register the session workspace', {
|
|
1072
|
+
path,
|
|
1073
|
+
message: cause instanceof Error ? cause.message : String(cause),
|
|
1074
|
+
})
|
|
1075
|
+
}
|
|
1076
|
+
}
|