@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,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
|
+
}
|