@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,311 @@
1
+ import { truncate } from '../core/text.ts'
2
+ import type { DiffLine } from '../model/message.ts'
3
+
4
+ export const SUMMARY_SAFETY_MAX = 300
5
+
6
+ export function relativize(path: string, cwd: string): string {
7
+ const flatPath = flat(path)
8
+ if (flatPath === cwd) return '.'
9
+ if (flatPath.startsWith(`${cwd}/`)) return flatPath.slice(cwd.length + 1)
10
+ return flatPath
11
+ }
12
+
13
+ export function truncateSummary(text: string, max = SUMMARY_SAFETY_MAX): string {
14
+ const cut = truncate(text, max)
15
+ return cut.length === text.length ? cut : `${cut}...`
16
+ }
17
+
18
+ function flat(text: string): string {
19
+ return text.replace(/\r\n|\r|\n/g, '\\n').replace(/\t/g, '\\t')
20
+ }
21
+
22
+ /**
23
+ * Generic primary-param picker: among single-line values (short strings,
24
+ * numbers, booleans) choose the shortest one, so the header suffix stays as
25
+ * short as possible — a human summary beats a long payload. Empty and
26
+ * multi-line strings, arrays, and objects never qualify.
27
+ */
28
+ export function pickPrimaryParam(args: unknown): { key: string; value: string } | undefined {
29
+ if (typeof args !== 'object' || args === null) return undefined
30
+ let best: { key: string; value: string } | undefined
31
+ for (const [key, value] of Object.entries(args as Record<string, unknown>)) {
32
+ const text = singleLineText(value)
33
+ if (text === undefined) continue
34
+ if (best === undefined || text.length < best.value.length) best = { key, value: text }
35
+ }
36
+ return best
37
+ }
38
+
39
+ function singleLineText(value: unknown): string | undefined {
40
+ if (value === undefined || value === null) return undefined
41
+ if (typeof value === 'string') return value === '' || value.includes('\n') ? undefined : value
42
+ if (typeof value === 'number' || typeof value === 'boolean') return String(value)
43
+ return undefined
44
+ }
45
+
46
+ /** JSON of the remaining args after the primary key, or '' when nothing remains. */
47
+ export function remainingArgsJson(args: unknown, excludeKey: string | undefined): string {
48
+ if (typeof args !== 'object' || args === null) {
49
+ if (typeof args === 'string' && args !== '') return args
50
+ return ''
51
+ }
52
+ if (excludeKey !== undefined) {
53
+ const rest = { ...(args as Record<string, unknown>) }
54
+ delete rest[excludeKey]
55
+ if (Object.keys(rest).length === 0) return ''
56
+ return JSON.stringify(rest, null, 2) ?? ''
57
+ }
58
+ return JSON.stringify(args, null, 2) ?? ''
59
+ }
60
+
61
+ /** The path part of a diff-card title (`Write foo.txt` -> `foo.txt`). */
62
+ export function pathFromTitle(title: string | undefined, fallback: string): string {
63
+ if (title === undefined) return fallback
64
+ const rest = title.trim().split(/\s+/).slice(1).join(' ')
65
+ return rest === '' ? fallback : rest
66
+ }
67
+
68
+ export interface DiffLike {
69
+ path: string
70
+ oldText: string | null
71
+ newText: string
72
+ }
73
+
74
+ interface DiffOp {
75
+ kind: 'ctx' | 'del' | 'add'
76
+ text: string
77
+ }
78
+
79
+ function alignDiff(oldLines: string[], newLines: string[]): DiffOp[] {
80
+ const n = oldLines.length
81
+ const m = newLines.length
82
+ const dp: number[][] = Array.from({ length: n + 1 }, () => new Array<number>(m + 1).fill(0))
83
+ for (let i = n - 1; i >= 0; i--) {
84
+ for (let j = m - 1; j >= 0; j--) {
85
+ dp[i]![j] = oldLines[i] === newLines[j]
86
+ ? dp[i + 1]![j + 1]! + 1
87
+ : Math.max(dp[i + 1]![j]!, dp[i]![j + 1]!)
88
+ }
89
+ }
90
+ const ops: DiffOp[] = []
91
+ let i = 0
92
+ let j = 0
93
+ while (i < n && j < m) {
94
+ if (oldLines[i] === newLines[j]) {
95
+ ops.push({ kind: 'ctx', text: oldLines[i]! })
96
+ i++
97
+ j++
98
+ } else if (dp[i + 1]![j]! >= dp[i]![j + 1]!) {
99
+ ops.push({ kind: 'del', text: oldLines[i]! })
100
+ i++
101
+ } else {
102
+ ops.push({ kind: 'add', text: newLines[j]! })
103
+ j++
104
+ }
105
+ }
106
+ while (i < n) {
107
+ ops.push({ kind: 'del', text: oldLines[i]! })
108
+ i++
109
+ }
110
+ while (j < m) {
111
+ ops.push({ kind: 'add', text: newLines[j]! })
112
+ j++
113
+ }
114
+ return ops
115
+ }
116
+
117
+ export function formatDiffDiffs(diffs: readonly DiffLike[]): string {
118
+ const lines: string[] = []
119
+ for (const diff of diffs) {
120
+ for (const op of alignedOps(diff.oldText, diff.newText)) {
121
+ if (op.kind === 'ctx') lines.push(` ${op.text}`)
122
+ else if (op.kind === 'del') lines.push(`- ${op.text}`)
123
+ else lines.push(`+ ${op.text}`)
124
+ }
125
+ }
126
+ return lines.join('\n')
127
+ }
128
+
129
+ export function diffLineGroups(diffs: readonly DiffLike[]): DiffLine[][] {
130
+ const groups: DiffLine[][] = []
131
+ for (const diff of diffs) {
132
+ groups.push(alignedOps(diff.oldText, diff.newText).map(op => ({ kind: op.kind, text: op.text })))
133
+ }
134
+ return groups
135
+ }
136
+
137
+ export interface ToolDiffView {
138
+ path: string
139
+ hunks: readonly (readonly DiffLine[])[]
140
+ /** Override the default "paint backgrounds when a removal exists" rule. */
141
+ backgrounds?: boolean
142
+ }
143
+
144
+ /**
145
+ * Body for a `card: 'generic'` call: the salient `rawInput` (string verbatim,
146
+ * anything else as pretty JSON) followed by the view's content text. A part
147
+ * the header already carries is dropped — the background-bash view puts the
148
+ * command in both `title` and `rawInput`, and `job_output`'s id already rides
149
+ * the header. Empty when nothing remains.
150
+ */
151
+ export function genericCallBody(rawInput: unknown, contentText: string, headerSuffix: string): string {
152
+ const raw =
153
+ typeof rawInput === 'string' && rawInput !== '' ? rawInput
154
+ : rawInput !== undefined && rawInput !== null ? stringifyValue(rawInput)
155
+ : ''
156
+ const duplicated = (text: string): boolean =>
157
+ text === headerSuffix || (text.length >= 4 && headerSuffix.includes(text))
158
+ const parts: string[] = []
159
+ if (raw !== '' && !duplicated(raw)) parts.push(raw)
160
+ if (contentText !== '' && !duplicated(contentText) && contentText !== raw) parts.push(contentText)
161
+ return parts.join('\n\n')
162
+ }
163
+
164
+ /**
165
+ * Header suffix for a `card: 'generic'` call: the description wins when the
166
+ * view carries one; otherwise the shorter of the title and a single-line
167
+ * content text — a summary title beats a raw-command one.
168
+ */
169
+ export function genericCallHeader(name: string, view: { title?: string; description?: string }, contentText: string): string {
170
+ if (view.description !== undefined && view.description !== '' && !view.description.includes('\n')) {
171
+ return view.description
172
+ }
173
+ const title = dedupeTitle(name, view.title ?? '')
174
+ const contentFirst = contentText.includes('\n') ? '' : contentText.trim()
175
+ if (contentFirst !== '' && (title === '' || contentFirst.length < title.length)) return contentFirst
176
+ return title
177
+ }
178
+
179
+ /** Drop trailing blank lines a result text often ends with. */
180
+ export function trimTrailingBlanks(text: string): string {
181
+ return text.replace(/[\s]+$/u, '')
182
+ }
183
+
184
+ /** Collapse `.` and `..` segments so a display path names a real directory. */
185
+ export function collapseDotSegments(path: string): string {
186
+ if (!/(?:^|[/\\])\.\.?(?:[/\\]|$)/.test(path)) return path
187
+ const separator = path.includes('\\') && !path.includes('/') ? '\\' : '/'
188
+ const rooted = path.startsWith('/') || path.startsWith('\\')
189
+ const kept: string[] = []
190
+ for (const segment of path.split(/[/\\]/)) {
191
+ if (segment === '' || segment === '.') continue
192
+ if (segment === '..') {
193
+ if (kept.length > 0 && kept[kept.length - 1] !== '..') kept.pop()
194
+ else if (!rooted) kept.push('..')
195
+ continue
196
+ }
197
+ kept.push(segment)
198
+ }
199
+ const body = kept.join(separator)
200
+ return rooted ? `${separator}${body}` : body
201
+ }
202
+
203
+ /**
204
+ * The body line of a terminal call. An explicitly declared workdir renders as
205
+ * a shell-style prompt prefix (`dir $ cmd`), resolved against the session
206
+ * workspace and relativized; an omitted cwd IS the session workspace, so the
207
+ * command renders bare.
208
+ */
209
+ export function terminalCallBody(title: string | undefined, viewCwd: string | undefined, sessionCwd: string): string {
210
+ const command = title ?? ''
211
+ if (viewCwd === undefined || viewCwd === '') return command
212
+ const absolute = /^(?:[A-Za-z]:)?[/\\]/.test(viewCwd)
213
+ const resolved = absolute ? viewCwd : `${sessionCwd.replace(/[/\\]+$/, '')}/${viewCwd}`
214
+ const display = relativize(collapseDotSegments(resolved), sessionCwd)
215
+ if (display === '.' || display === '') return command
216
+ return `${display} $ ${command}`
217
+ }
218
+
219
+ function stringifyValue(value: unknown): string {
220
+ try {
221
+ return JSON.stringify(value, null, 2) ?? ''
222
+ } catch {
223
+ // Cyclic or unserializable values degrade to an empty body.
224
+ return ''
225
+ }
226
+ }
227
+
228
+ /**
229
+ * The recovery line a truncated result keeps in its raw text: the spill
230
+ * footer the search tools append (`Full ... stored at ...`). The structured
231
+ * card replaces the raw body, so this line is surfaced after it to keep the
232
+ * one path to the dropped rows. Empty when the raw text carries no footer.
233
+ */
234
+ export function recoveryLines(resultText: string): string[] {
235
+ const lines = resultText.split('\n')
236
+ for (let index = lines.length - 1; index >= 0; index--) {
237
+ if (/stored at|retrieval hint/i.test(lines[index]!)) return [lines[index]!.trimEnd()]
238
+ }
239
+ return []
240
+ }
241
+
242
+ /** A search result as flat lines: paths, or matches grouped under file paths. */
243
+ export function formatSearchView(view: { shape?: 'paths' | 'matches'; paths?: readonly string[]; files?: readonly { path: string; matches: readonly { lineNumber: number; line: string }[] }[] }): string {
244
+ const lines: string[] = []
245
+ if (view.shape === 'paths') {
246
+ lines.push(...(view.paths ?? []))
247
+ } else {
248
+ for (const file of view.files ?? []) {
249
+ lines.push(file.path)
250
+ for (const match of file.matches) lines.push(`${match.lineNumber}: ${match.line}`)
251
+ }
252
+ }
253
+ return lines.join('\n')
254
+ }
255
+
256
+ export function diffsFromResultMeta(meta: unknown): DiffLike[] | undefined {
257
+ if (typeof meta !== 'object' || meta === null || Array.isArray(meta)) return undefined
258
+ const raw = (meta as Record<string, unknown>).diffs
259
+ if (!Array.isArray(raw) || raw.length === 0) return undefined
260
+ const diffs: DiffLike[] = []
261
+ for (const entry of raw) {
262
+ if (typeof entry !== 'object' || entry === null) return undefined
263
+ const record = entry as Record<string, unknown>
264
+ if (typeof record.path !== 'string') return undefined
265
+ if (record.oldText !== null && typeof record.oldText !== 'string') return undefined
266
+ if (typeof record.newText !== 'string') return undefined
267
+ diffs.push({ path: record.path, oldText: record.oldText, newText: record.newText })
268
+ }
269
+ return diffs
270
+ }
271
+
272
+ const DIFF_CELL_CAP = 4_000_000
273
+
274
+ /** Split on newlines where a trailing `\n` terminates the last line instead of opening an empty one. */
275
+ export function splitLines(text: string): string[] {
276
+ if (text === '') return []
277
+ return text.endsWith('\n') ? text.slice(0, -1).split('\n') : text.split('\n')
278
+ }
279
+
280
+ function alignedOps(oldText: string | null, newText: string): DiffOp[] {
281
+ const additionsOnly = (): DiffOp[] => alignDiff([], splitLines(newText))
282
+ if (oldText === null || oldText === '') return additionsOnly()
283
+ const oldLines = splitLines(oldText)
284
+ const newLines = splitLines(newText)
285
+ if (oldLines.length * Math.max(1, newLines.length) > DIFF_CELL_CAP) return additionsOnly()
286
+ return alignDiff(oldLines, newLines)
287
+ }
288
+
289
+ export interface ReadLineLike {
290
+ number: number
291
+ text: string
292
+ }
293
+
294
+ /** Generic title dedup against the tool name, applied to every protocol:
295
+ * 1. title === name (case-insensitive) -> '' (ralph / ralph)
296
+ * 2. title starts with "name:" -> strip prefix (workflow: x -> x)
297
+ * 3. title's first word === name's stem -> strip first word
298
+ * (create_goal + "Create goal" -> "goal"; grep + "Grep x" -> "x")
299
+ * 4. otherwise title verbatim
300
+ */
301
+ export function dedupeTitle(name: string, title: string): string {
302
+ const trimmed = title.trim()
303
+ if (trimmed === '') return ''
304
+ if (trimmed.toLowerCase() === name.toLowerCase()) return ''
305
+ const colon = /^([a-z0-9_-]+):\s*(.*)$/i.exec(trimmed)
306
+ if (colon !== null && colon[1]!.toLowerCase() === name.toLowerCase()) return truncateSummary(colon[2]!)
307
+ const words = trimmed.split(/\s+/)
308
+ const stem = name.split('_')[0]!.toLowerCase()
309
+ if (words[0]!.toLowerCase() === stem) return truncateSummary(words.slice(1).join(' '))
310
+ return truncateSummary(trimmed)
311
+ }
@@ -0,0 +1,114 @@
1
+ import { keyedRegistry } from '../kernel/registry.ts'
2
+ import type { ToolViewContribution } from '../contract/index.ts'
3
+ import type { ChatToolPresenter } from './bridge.ts'
4
+
5
+ const contributions = keyedRegistry<ToolViewContribution>()
6
+
7
+ export function registerToolView(contribution: ToolViewContribution): () => void {
8
+ return contributions.register(contribution.tool, contribution, { order: contribution.order })
9
+ }
10
+
11
+ export function toolViewOf(tool: string): ToolViewContribution | undefined {
12
+ return contributions.get(tool)
13
+ }
14
+
15
+ export function subscribeToolViews(listener: () => void): () => void {
16
+ return contributions.subscribe(listener)
17
+ }
18
+
19
+ const MAX_TOOL_CALLS = 1000
20
+
21
+ export interface RememberedToolCall {
22
+ name: string
23
+ args: unknown
24
+ /** Pretty JSON of the arguments; non-JSON arguments echo the raw string. */
25
+ argsJson: string
26
+ }
27
+
28
+ /**
29
+ * The one call ledger pairing tool/call ids with their parsed arguments.
30
+ * Both the contribution layer and the builtin presenter read it, so a call
31
+ * that a contribution fully takes over still resolves at result time, and
32
+ * non-JSON arguments survive as the raw string instead of being dropped.
33
+ */
34
+ export class ToolCallLedger {
35
+ private readonly calls = new Map<string, RememberedToolCall>()
36
+
37
+ remember(name: string, callId: string, argumentsRaw: string): RememberedToolCall {
38
+ let args: unknown
39
+ let json: string
40
+ try {
41
+ args = JSON.parse(argumentsRaw) as unknown
42
+ json = JSON.stringify(args, null, 2) ?? ''
43
+ } catch {
44
+ // Non-JSON arguments survive verbatim as the raw string instead of being dropped.
45
+ args = argumentsRaw
46
+ json = argumentsRaw
47
+ }
48
+ if (!this.calls.has(callId) && this.calls.size >= MAX_TOOL_CALLS) {
49
+ const oldest = this.calls.keys().next()
50
+ if (!oldest.done) this.calls.delete(oldest.value)
51
+ }
52
+ const call = { name, args, argsJson: json }
53
+ this.calls.set(callId, call)
54
+ return call
55
+ }
56
+
57
+ get(callId: string): RememberedToolCall | undefined {
58
+ return this.calls.get(callId)
59
+ }
60
+
61
+ argsJson(callId: string): string | undefined {
62
+ return this.calls.get(callId)?.argsJson
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Compose the builtin presenter under the contribution registry: a keyed hit
68
+ * is offered the call/result first and the builtin path only runs on a miss.
69
+ * Every call lands in the shared ledger before routing, whatever handles it.
70
+ */
71
+ export function createToolViewPresenter(
72
+ inner: ChatToolPresenter,
73
+ options: { resolve(tool: string): ToolViewContribution | undefined; cwd(): string; ledger: ToolCallLedger },
74
+ ): ChatToolPresenter {
75
+ const ledger = options.ledger
76
+ return {
77
+ call(name, callId, argumentsRaw) {
78
+ const call = ledger.remember(name, callId, argumentsRaw)
79
+ const contribution = options.resolve(name)
80
+ const contributed = contribution?.call
81
+ if (contributed !== undefined) {
82
+ const presented = contributed({ tool: name, callId, args: call.args, argumentsRaw, cwd: options.cwd() })
83
+ if (presented !== undefined) {
84
+ return {
85
+ label: presented.label ?? name,
86
+ body: presented.body ?? '',
87
+ ...(presented.bodyCol === undefined ? {} : { bodyCol: presented.bodyCol }),
88
+ ...(presented.diff === undefined ? {} : { diff: presented.diff }),
89
+ }
90
+ }
91
+ if (contribution?.takeover === true) return { label: name, body: '' }
92
+ }
93
+ return inner.call(name, callId, argumentsRaw) ?? { label: name, body: '' }
94
+ },
95
+ result(callId, result) {
96
+ const call = ledger.get(callId)
97
+ if (call !== undefined) {
98
+ const contribution = options.resolve(call.name)
99
+ if (contribution !== undefined) {
100
+ const contributed = contribution.result
101
+ if (contributed !== undefined) {
102
+ const presented = contributed({ tool: call.name, callId, args: call.args, result, cwd: options.cwd() })
103
+ if (presented !== undefined) return presented
104
+ }
105
+ if (contribution.takeover === true) return { kind: 'replace', text: '' }
106
+ }
107
+ } else if (result.isError !== true) {
108
+ return { kind: 'replace', text: '' }
109
+ }
110
+ return inner.result(callId, result)
111
+ },
112
+ argsJson: callId => ledger.argsJson(callId) ?? inner.argsJson(callId),
113
+ }
114
+ }