@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.
Files changed (159) hide show
  1. package/bin/dshtui.js +65 -0
  2. package/cordis.patch.yml +81 -0
  3. package/lib/contract/index.d.mts +2 -0
  4. package/lib/contract/index.mjs +5 -0
  5. package/lib/dialog.d.mts +156 -0
  6. package/lib/dialog.mjs +3 -0
  7. package/lib/index-DqnaSXqP.d.mts +823 -0
  8. package/lib/index.d.mts +17 -0
  9. package/lib/index.mjs +11404 -0
  10. package/lib/prod-react-Dzof4qJx.mjs +4 -0
  11. package/lib/surface-DdxzIgIY.mjs +3126 -0
  12. package/lib/vendor.d.mts +3 -0
  13. package/lib/vendor.mjs +4 -0
  14. package/package.json +128 -0
  15. package/src/apply-theme.ts +8 -0
  16. package/src/boot-log.ts +35 -0
  17. package/src/chat/blocks.ts +41 -0
  18. package/src/chat/bridge.ts +1076 -0
  19. package/src/chat/builtin-tool-views.ts +53 -0
  20. package/src/chat/chat-face.ts +16 -0
  21. package/src/chat/chat-nodes.ts +53 -0
  22. package/src/chat/efforts.ts +4 -0
  23. package/src/chat/interactions.ts +329 -0
  24. package/src/chat/models.ts +263 -0
  25. package/src/chat/partial-json.ts +125 -0
  26. package/src/chat/presets.ts +26 -0
  27. package/src/chat/question-view.ts +106 -0
  28. package/src/chat/retry-status.ts +33 -0
  29. package/src/chat/session-groups.ts +34 -0
  30. package/src/chat/session-list.ts +311 -0
  31. package/src/chat/store.ts +636 -0
  32. package/src/chat/todo-view.ts +66 -0
  33. package/src/chat/tool-view.ts +311 -0
  34. package/src/chat/tool-views.ts +114 -0
  35. package/src/contract/index.ts +704 -0
  36. package/src/contract/upstream.ts +165 -0
  37. package/src/core/caret-nonce.ts +19 -0
  38. package/src/core/composer-layout.ts +65 -0
  39. package/src/core/edit.ts +152 -0
  40. package/src/core/field-view.ts +52 -0
  41. package/src/core/fields.ts +185 -0
  42. package/src/core/metrics.ts +26 -0
  43. package/src/core/paste.ts +149 -0
  44. package/src/core/segments.ts +112 -0
  45. package/src/core/text.ts +290 -0
  46. package/src/dialog.ts +11 -0
  47. package/src/env.ts +38 -0
  48. package/src/harness-home.ts +22 -0
  49. package/src/hot-theme.ts +56 -0
  50. package/src/index.tsx +185 -0
  51. package/src/kernel/registry.ts +102 -0
  52. package/src/kernel/surface.ts +33 -0
  53. package/src/log.ts +249 -0
  54. package/src/model/message.ts +89 -0
  55. package/src/model/selection.ts +63 -0
  56. package/src/performance-guard.ts +28 -0
  57. package/src/runtime/prod-react.ts +10 -0
  58. package/src/terminal/background.ts +66 -0
  59. package/src/terminal/capabilities.ts +54 -0
  60. package/src/terminal/clipboard-backends.ts +69 -0
  61. package/src/terminal/clipboard.ts +161 -0
  62. package/src/terminal/cursor-shape.ts +13 -0
  63. package/src/terminal/glyphs.ts +44 -0
  64. package/src/terminal/mouse.ts +154 -0
  65. package/src/terminal/probe.ts +61 -0
  66. package/src/terminal/screen.ts +349 -0
  67. package/src/theme-settings.ts +31 -0
  68. package/src/theme.ts +319 -0
  69. package/src/ui/app.tsx +454 -0
  70. package/src/ui/chrome/caps.tsx +9 -0
  71. package/src/ui/chrome/hint-service.ts +87 -0
  72. package/src/ui/chrome/input-status.ts +27 -0
  73. package/src/ui/chrome/key-gate.tsx +29 -0
  74. package/src/ui/chrome/status-bar.tsx +147 -0
  75. package/src/ui/contributions.ts +47 -0
  76. package/src/ui/dialog/defaults-dialog.tsx +152 -0
  77. package/src/ui/dialog/dialog-item.tsx +38 -0
  78. package/src/ui/dialog/dialog.tsx +386 -0
  79. package/src/ui/dialog/effort-dialog.tsx +45 -0
  80. package/src/ui/dialog/geometry.ts +131 -0
  81. package/src/ui/dialog/items.ts +124 -0
  82. package/src/ui/dialog/list-dialog.tsx +63 -0
  83. package/src/ui/dialog/models-dialog.tsx +203 -0
  84. package/src/ui/dialog/presets-dialog.tsx +46 -0
  85. package/src/ui/dialog/providers-dialog.tsx +313 -0
  86. package/src/ui/dialog/sessions-dialog.tsx +163 -0
  87. package/src/ui/dialog/sizes.ts +7 -0
  88. package/src/ui/dialog/status-lines.ts +11 -0
  89. package/src/ui/dialog/todo-dialog.tsx +29 -0
  90. package/src/ui/dialog/use-dialog-input.ts +186 -0
  91. package/src/ui/extension-point.ts +188 -0
  92. package/src/ui/home-logo.ts +132 -0
  93. package/src/ui/hooks/use-async-action.ts +25 -0
  94. package/src/ui/hooks/use-async-list.ts +71 -0
  95. package/src/ui/hooks/use-caret.ts +24 -0
  96. package/src/ui/hooks/use-chat-events.ts +153 -0
  97. package/src/ui/hooks/use-mouse-selection.ts +352 -0
  98. package/src/ui/hooks/use-scroll.ts +49 -0
  99. package/src/ui/hooks/use-terminal-size.ts +20 -0
  100. package/src/ui/input/commands.ts +118 -0
  101. package/src/ui/input/composer-bus.ts +14 -0
  102. package/src/ui/input/composer-fields.ts +111 -0
  103. package/src/ui/input/composer-keys.ts +21 -0
  104. package/src/ui/input/composer-paste.ts +20 -0
  105. package/src/ui/input/input-bar.tsx +279 -0
  106. package/src/ui/input/use-composer.ts +495 -0
  107. package/src/ui/key-arbiter.ts +37 -0
  108. package/src/ui/keymap.ts +15 -0
  109. package/src/ui/layout-service.ts +77 -0
  110. package/src/ui/message/layout.ts +763 -0
  111. package/src/ui/message/md/block.ts +247 -0
  112. package/src/ui/message/md/engine.ts +430 -0
  113. package/src/ui/message/md/extensions.ts +58 -0
  114. package/src/ui/message/md/highlight.ts +626 -0
  115. package/src/ui/message/md/index.ts +14 -0
  116. package/src/ui/message/md/inline.ts +97 -0
  117. package/src/ui/message/md/palette.ts +87 -0
  118. package/src/ui/message/message-list.tsx +69 -0
  119. package/src/ui/message/message-row.tsx +137 -0
  120. package/src/ui/message/message-views.ts +16 -0
  121. package/src/ui/message/renderers.ts +39 -0
  122. package/src/ui/message/tool-diff.ts +188 -0
  123. package/src/ui/message/warmup.ts +41 -0
  124. package/src/ui/message/wave.ts +98 -0
  125. package/src/ui/overlay.ts +22 -0
  126. package/src/ui/panels/approval-panel.tsx +193 -0
  127. package/src/ui/panels/exports.ts +3 -0
  128. package/src/ui/panels/question-model.ts +424 -0
  129. package/src/ui/panels/question-panel.tsx +154 -0
  130. package/src/ui/panels/surface.tsx +106 -0
  131. package/src/ui/panels-builtin.tsx +86 -0
  132. package/src/ui/pointer/builtins.ts +281 -0
  133. package/src/ui/pointer/registry.ts +25 -0
  134. package/src/ui/region.tsx +31 -0
  135. package/src/ui/selection/service.ts +92 -0
  136. package/src/ui/selection-registry.ts +88 -0
  137. package/src/ui/selection.tsx +148 -0
  138. package/src/ui/spinner-tick.tsx +24 -0
  139. package/src/ui/use-available-windows.ts +31 -0
  140. package/src/ui/widgets/actions.tsx +84 -0
  141. package/src/ui/widgets/button.tsx +57 -0
  142. package/src/ui/widgets/checkbox.tsx +58 -0
  143. package/src/ui/widgets/header.tsx +36 -0
  144. package/src/ui/widgets/index.ts +8 -0
  145. package/src/ui/widgets/input.tsx +76 -0
  146. package/src/ui/widgets/registry.ts +32 -0
  147. package/src/ui/widgets/search.tsx +59 -0
  148. package/src/ui/widgets/select.tsx +97 -0
  149. package/src/ui/widgets/static.tsx +37 -0
  150. package/src/ui/widgets/types.ts +1 -0
  151. package/src/ui/window-services-bridge.ts +71 -0
  152. package/src/ui/window-services.ts +20 -0
  153. package/src/ui/windows/models-window.tsx +15 -0
  154. package/src/ui/windows/providers-window.tsx +14 -0
  155. package/src/ui/windows/service-window.tsx +18 -0
  156. package/src/ui/windows/sessions-window.tsx +25 -0
  157. package/src/ui/windows-builtin.tsx +94 -0
  158. package/src/ui/windows.ts +18 -0
  159. 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
+ }