dsh-code 1.0.0 → 1.0.2
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/README.en.md +278 -265
- package/README.md +277 -264
- package/cordis.patch.yml +7 -0
- package/lib/index.mjs +1649 -575
- package/lib/types/app.d.ts +19 -14
- package/lib/types/attachments.d.ts +14 -1
- package/lib/types/authorization-panel.d.ts +22 -0
- package/lib/types/authorization.d.ts +36 -0
- package/lib/types/keyboard.d.ts +32 -3
- package/lib/types/mentions.d.ts +4 -0
- package/lib/types/models.d.ts +3 -1
- package/lib/types/permissions.d.ts +4 -14
- package/lib/types/presets.d.ts +4 -17
- package/lib/types/render/animations.d.ts +27 -11
- package/lib/types/render/editor.d.ts +32 -7
- package/lib/types/render/lines.d.ts +1 -1
- package/lib/types/render/status.d.ts +1 -1
- package/package.json +49 -43
- package/src/app.ts +4570 -4188
- package/src/attachments.ts +93 -2
- package/src/authorization-panel.ts +285 -0
- package/src/authorization.ts +147 -0
- package/src/index.ts +34 -21
- package/src/internals.ts +26 -9
- package/src/keyboard.ts +164 -39
- package/src/mentions.ts +12 -1
- package/src/models.ts +20 -14
- package/src/permissions.ts +5 -13
- package/src/presets.ts +5 -18
- package/src/provider-settings.ts +1 -1
- package/src/render/animations.ts +447 -400
- package/src/render/editor.ts +125 -25
- package/src/render/lines.ts +16 -2
- package/src/render/projection.ts +7 -3
- package/src/render/status.ts +2 -2
- package/src/session-directory.ts +1 -1
package/src/attachments.ts
CHANGED
|
@@ -1,10 +1,21 @@
|
|
|
1
1
|
/** Terminal image-file adapter over the Harness durable attachment service. */
|
|
2
2
|
|
|
3
|
-
import { readFile } from 'node:fs/promises'
|
|
4
|
-
import {
|
|
3
|
+
import { open, readFile, stat } from 'node:fs/promises'
|
|
4
|
+
import { fileURLToPath } from 'node:url'
|
|
5
|
+
import { basename, extname, isAbsolute, resolve } from 'node:path'
|
|
5
6
|
import type { AttachmentStore, ImageMediaType, SaveImageAttachment } from '@deepseek-ai/dsh-attachment'
|
|
6
7
|
import type { ImageBlock } from '@deepseek-ai/dsh-llm'
|
|
7
8
|
|
|
9
|
+
/** A validated path retained in the editor until submission persists it. */
|
|
10
|
+
export interface ImagePathInspection {
|
|
11
|
+
readonly path: string
|
|
12
|
+
readonly name: string
|
|
13
|
+
readonly mediaType: ImageMediaType
|
|
14
|
+
readonly bytes: number
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.webp', '.gif'])
|
|
18
|
+
|
|
8
19
|
/** Detect the supported encoded raster formats from bytes, never from a path suffix. */
|
|
9
20
|
export function detectImageMediaType(data: Uint8Array): ImageMediaType | undefined {
|
|
10
21
|
if (data.length >= 8 && data[0] === 0x89 && data[1] === 0x50 && data[2] === 0x4e && data[3] === 0x47
|
|
@@ -20,15 +31,93 @@ export function detectImageMediaType(data: Uint8Array): ImageMediaType | undefin
|
|
|
20
31
|
return undefined
|
|
21
32
|
}
|
|
22
33
|
|
|
34
|
+
/** Whether a path-like token is worth probing as an image attachment. */
|
|
35
|
+
export function looksLikeImagePath(path: string): boolean {
|
|
36
|
+
return IMAGE_EXTENSIONS.has(extname(path).toLowerCase())
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Parse a terminal paste/drop containing only one or more image paths. */
|
|
40
|
+
export function parsePastedImagePaths(input: string): readonly string[] {
|
|
41
|
+
const text = input.trim()
|
|
42
|
+
if (text === '') return []
|
|
43
|
+
const tokens: string[] = []
|
|
44
|
+
const matcher = /"([^"]+)"|'([^']+)'|(\S+)/gu
|
|
45
|
+
for (const match of text.matchAll(matcher)) {
|
|
46
|
+
const token = match[1] ?? match[2] ?? match[3]
|
|
47
|
+
if (token === undefined) continue
|
|
48
|
+
let path = token
|
|
49
|
+
if (path.startsWith('file://')) {
|
|
50
|
+
try {
|
|
51
|
+
path = fileURLToPath(path)
|
|
52
|
+
} catch {
|
|
53
|
+
return []
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (!looksLikeImagePath(path)) return []
|
|
57
|
+
tokens.push(path)
|
|
58
|
+
}
|
|
59
|
+
return tokens
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Validate path, byte size and encoded signature without writing an attachment object. */
|
|
63
|
+
export async function inspectImagePaths(
|
|
64
|
+
paths: readonly string[],
|
|
65
|
+
attachments: AttachmentStore | undefined,
|
|
66
|
+
cwd = process.cwd(),
|
|
67
|
+
): Promise<readonly ImagePathInspection[]> {
|
|
68
|
+
if (paths.length === 0) return []
|
|
69
|
+
if (attachments === undefined) throw new Error('image attachments are unavailable in this profile')
|
|
70
|
+
if (paths.length > attachments.imageLimits.maxImagesPerMessage) {
|
|
71
|
+
throw new Error(`too many images (${paths.length}; limit ${attachments.imageLimits.maxImagesPerMessage})`)
|
|
72
|
+
}
|
|
73
|
+
const inspected: ImagePathInspection[] = []
|
|
74
|
+
let totalBytes = 0
|
|
75
|
+
for (const raw of paths) {
|
|
76
|
+
const path = isAbsolute(raw) ? resolve(raw) : resolve(cwd, raw)
|
|
77
|
+
let facts: Awaited<ReturnType<typeof stat>>
|
|
78
|
+
try {
|
|
79
|
+
facts = await stat(path)
|
|
80
|
+
} catch (error: unknown) {
|
|
81
|
+
throw new Error(`cannot read image "${raw}": ${error instanceof Error ? error.message : String(error)}`)
|
|
82
|
+
}
|
|
83
|
+
if (!facts.isFile()) throw new Error(`image path is not a file: "${raw}"`)
|
|
84
|
+
if (facts.size > attachments.imageLimits.maxImageBytes) {
|
|
85
|
+
throw new Error(`image "${basename(path)}" is ${facts.size} bytes; limit ${attachments.imageLimits.maxImageBytes}`)
|
|
86
|
+
}
|
|
87
|
+
totalBytes += facts.size
|
|
88
|
+
if (totalBytes > attachments.imageLimits.maxMessageImageBytes) {
|
|
89
|
+
throw new Error(`image batch is ${totalBytes} bytes; limit ${attachments.imageLimits.maxMessageImageBytes}`)
|
|
90
|
+
}
|
|
91
|
+
const handle = await open(path, 'r')
|
|
92
|
+
try {
|
|
93
|
+
const signature = new Uint8Array(16)
|
|
94
|
+
const { bytesRead } = await handle.read(signature, 0, signature.length, 0)
|
|
95
|
+
const mediaType = detectImageMediaType(signature.subarray(0, bytesRead))
|
|
96
|
+
if (mediaType === undefined || !attachments.imageLimits.mediaTypes.includes(mediaType)) {
|
|
97
|
+
throw new Error(`unsupported image file "${raw}" (expected PNG, JPEG, WebP, or GIF)`)
|
|
98
|
+
}
|
|
99
|
+
inspected.push({ path, name: basename(path), mediaType, bytes: facts.size })
|
|
100
|
+
} finally {
|
|
101
|
+
await handle.close()
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return inspected
|
|
105
|
+
}
|
|
106
|
+
|
|
23
107
|
/** Read, validate, and persist an ordered image path list as model content blocks. */
|
|
24
108
|
export async function saveImagePaths(
|
|
25
109
|
paths: readonly string[],
|
|
26
110
|
attachments: AttachmentStore | undefined,
|
|
111
|
+
signal?: AbortSignal,
|
|
27
112
|
): Promise<readonly ImageBlock[]> {
|
|
28
113
|
if (paths.length === 0) return []
|
|
29
114
|
if (attachments === undefined) throw new Error('image attachments are unavailable in this profile')
|
|
115
|
+
const checkCancelled = (): void => {
|
|
116
|
+
if (signal?.aborted === true) throw new Error('image submission cancelled')
|
|
117
|
+
}
|
|
30
118
|
const inputs: SaveImageAttachment[] = []
|
|
31
119
|
for (const path of paths) {
|
|
120
|
+
checkCancelled()
|
|
32
121
|
let data: Uint8Array
|
|
33
122
|
try {
|
|
34
123
|
data = await readFile(path)
|
|
@@ -39,6 +128,8 @@ export async function saveImagePaths(
|
|
|
39
128
|
if (mediaType === undefined) throw new Error(`unsupported image file "${path}" (expected PNG, JPEG, WebP, or GIF)`)
|
|
40
129
|
inputs.push({ data, mediaType, name: basename(path) })
|
|
41
130
|
}
|
|
131
|
+
checkCancelled()
|
|
42
132
|
const refs = await attachments.saveImages(inputs)
|
|
133
|
+
checkCancelled()
|
|
43
134
|
return refs.map(attachment => ({ type: 'image', attachment }))
|
|
44
135
|
}
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
/** Bounded Ink surfaces for provider login and logout. */
|
|
2
|
+
|
|
3
|
+
import { createElement, useEffect, useRef, useState, type ReactElement } from 'react'
|
|
4
|
+
import { Box, Text, useInput, useStdout } from 'ink'
|
|
5
|
+
import {
|
|
6
|
+
AuthorizationDeclinedError,
|
|
7
|
+
type AuthorizationInteraction,
|
|
8
|
+
type AuthorizationNotice,
|
|
9
|
+
type AuthorizationPrompt,
|
|
10
|
+
type AuthorizationStatus,
|
|
11
|
+
} from '@deepseek-ai/dsh-authorization'
|
|
12
|
+
import type { CredentialKey } from '@deepseek-ai/dsh-credentials'
|
|
13
|
+
import type { ProviderAuthorizationRow } from './authorization.ts'
|
|
14
|
+
import { panelViewport } from './render/inspector.ts'
|
|
15
|
+
import { displayText, singleLineText, truncateColumns } from './render/text.ts'
|
|
16
|
+
import { getPalette, inkColor } from './theme.ts'
|
|
17
|
+
|
|
18
|
+
interface PromptReply {
|
|
19
|
+
readonly resolve: (value: string) => void
|
|
20
|
+
readonly reject: (reason: Error) => void
|
|
21
|
+
readonly detach: () => void
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ProviderAuthorizationPanelProps {
|
|
25
|
+
readonly row: ProviderAuthorizationRow
|
|
26
|
+
begin(
|
|
27
|
+
row: ProviderAuthorizationRow,
|
|
28
|
+
method: string,
|
|
29
|
+
interaction: AuthorizationInteraction,
|
|
30
|
+
signal: AbortSignal,
|
|
31
|
+
): Promise<AuthorizationStatus>
|
|
32
|
+
cancel(key: CredentialKey): void
|
|
33
|
+
openUrl(url: string): boolean
|
|
34
|
+
copy(text: string): Promise<void>
|
|
35
|
+
done(): void
|
|
36
|
+
back(): void
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Run one upstream authorization flow without letting notices or prompts exceed the panel budget. */
|
|
40
|
+
export function ProviderAuthorizationPanel(props: ProviderAuthorizationPanelProps): ReactElement {
|
|
41
|
+
const stdout = useStdout().stdout
|
|
42
|
+
const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
|
|
43
|
+
const [phase, setPhase] = useState<'methods' | 'running'>('methods')
|
|
44
|
+
const [cursor, setCursor] = useState(0)
|
|
45
|
+
const [notices, setNotices] = useState<readonly AuthorizationNotice[]>([])
|
|
46
|
+
const [prompt, setPrompt] = useState<AuthorizationPrompt | undefined>(undefined)
|
|
47
|
+
const [draft, setDraft] = useState('')
|
|
48
|
+
const [promptCursor, setPromptCursor] = useState(0)
|
|
49
|
+
const [error, setError] = useState<string | undefined>(undefined)
|
|
50
|
+
const [copyState, setCopyState] = useState<string | undefined>(undefined)
|
|
51
|
+
const controllerRef = useRef<AbortController | undefined>(undefined)
|
|
52
|
+
const replyRef = useRef<PromptReply | undefined>(undefined)
|
|
53
|
+
const openedUrls = useRef(new Set<string>())
|
|
54
|
+
|
|
55
|
+
const clearReply = (): void => {
|
|
56
|
+
replyRef.current?.detach()
|
|
57
|
+
replyRef.current = undefined
|
|
58
|
+
setPrompt(undefined)
|
|
59
|
+
setDraft('')
|
|
60
|
+
setPromptCursor(0)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const decline = (): void => {
|
|
64
|
+
const reply = replyRef.current
|
|
65
|
+
clearReply()
|
|
66
|
+
reply?.reject(new AuthorizationDeclinedError())
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const stop = (): void => {
|
|
70
|
+
controllerRef.current?.abort()
|
|
71
|
+
controllerRef.current = undefined
|
|
72
|
+
props.cancel(props.row.key)
|
|
73
|
+
decline()
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
useEffect(() => () => {
|
|
77
|
+
controllerRef.current?.abort()
|
|
78
|
+
props.cancel(props.row.key)
|
|
79
|
+
const reply = replyRef.current
|
|
80
|
+
replyRef.current = undefined
|
|
81
|
+
reply?.detach()
|
|
82
|
+
reply?.reject(new AuthorizationDeclinedError())
|
|
83
|
+
}, [props.row.key])
|
|
84
|
+
|
|
85
|
+
const start = (method: string): void => {
|
|
86
|
+
setPhase('running')
|
|
87
|
+
setError(undefined)
|
|
88
|
+
setNotices([])
|
|
89
|
+
setCopyState(undefined)
|
|
90
|
+
const controller = new AbortController()
|
|
91
|
+
controllerRef.current = controller
|
|
92
|
+
const interaction: AuthorizationInteraction = {
|
|
93
|
+
notify(notice): void {
|
|
94
|
+
setNotices(current => [...current.slice(-19), notice])
|
|
95
|
+
if (notice.url !== undefined && !openedUrls.current.has(notice.url)) {
|
|
96
|
+
openedUrls.current.add(notice.url)
|
|
97
|
+
props.openUrl(notice.url)
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
prompt(next): Promise<string> {
|
|
101
|
+
return new Promise((resolve, reject) => {
|
|
102
|
+
const onWithdraw = (): void => {
|
|
103
|
+
if (replyRef.current?.reject !== reject) return
|
|
104
|
+
clearReply()
|
|
105
|
+
reject(new Error('authorization prompt was withdrawn'))
|
|
106
|
+
}
|
|
107
|
+
next.signal?.addEventListener('abort', onWithdraw, { once: true })
|
|
108
|
+
replyRef.current = {
|
|
109
|
+
resolve,
|
|
110
|
+
reject,
|
|
111
|
+
detach: () => next.signal?.removeEventListener('abort', onWithdraw),
|
|
112
|
+
}
|
|
113
|
+
setPrompt(next)
|
|
114
|
+
setDraft('')
|
|
115
|
+
setPromptCursor(0)
|
|
116
|
+
})
|
|
117
|
+
},
|
|
118
|
+
}
|
|
119
|
+
void props.begin(props.row, method, interaction, controller.signal).then((status) => {
|
|
120
|
+
controllerRef.current = undefined
|
|
121
|
+
clearReply()
|
|
122
|
+
if (status === 'authorized') props.done()
|
|
123
|
+
else props.back()
|
|
124
|
+
}, (reason: unknown) => {
|
|
125
|
+
controllerRef.current = undefined
|
|
126
|
+
clearReply()
|
|
127
|
+
if (controller.signal.aborted) {
|
|
128
|
+
props.back()
|
|
129
|
+
return
|
|
130
|
+
}
|
|
131
|
+
setError(reason instanceof Error ? reason.message : String(reason))
|
|
132
|
+
setPhase('methods')
|
|
133
|
+
})
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const answer = (value: string): void => {
|
|
137
|
+
const reply = replyRef.current
|
|
138
|
+
clearReply()
|
|
139
|
+
reply?.resolve(value)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
useInput((input, key) => {
|
|
143
|
+
if (phase === 'methods') {
|
|
144
|
+
if (key.escape || input === 'q') {
|
|
145
|
+
props.back()
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
if (props.row.methods.length === 0) return
|
|
149
|
+
if (key.upArrow) {
|
|
150
|
+
setCursor(current => (current + props.row.methods.length - 1) % props.row.methods.length)
|
|
151
|
+
return
|
|
152
|
+
}
|
|
153
|
+
if (key.downArrow) {
|
|
154
|
+
setCursor(current => (current + 1) % props.row.methods.length)
|
|
155
|
+
return
|
|
156
|
+
}
|
|
157
|
+
if (key.return) start(props.row.methods[cursor]?.id ?? props.row.methods[0]!.id)
|
|
158
|
+
return
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (key.escape) {
|
|
162
|
+
stop()
|
|
163
|
+
props.back()
|
|
164
|
+
return
|
|
165
|
+
}
|
|
166
|
+
const copyValue = notices.at(-1)?.code ?? notices.at(-1)?.url
|
|
167
|
+
if ((input === 'c' || input === 'C') && copyValue !== undefined) {
|
|
168
|
+
void props.copy(copyValue).then(
|
|
169
|
+
() => setCopyState('copied'),
|
|
170
|
+
reason => setCopyState(`copy failed: ${reason instanceof Error ? reason.message : String(reason)}`),
|
|
171
|
+
)
|
|
172
|
+
return
|
|
173
|
+
}
|
|
174
|
+
if (prompt === undefined) return
|
|
175
|
+
if (prompt.kind === 'select') {
|
|
176
|
+
if (prompt.options.length === 0) return
|
|
177
|
+
if (key.upArrow) {
|
|
178
|
+
setPromptCursor(current => (current + prompt.options.length - 1) % prompt.options.length)
|
|
179
|
+
return
|
|
180
|
+
}
|
|
181
|
+
if (key.downArrow) {
|
|
182
|
+
setPromptCursor(current => (current + 1) % prompt.options.length)
|
|
183
|
+
return
|
|
184
|
+
}
|
|
185
|
+
if (key.return) answer(prompt.options[promptCursor]?.id ?? prompt.options[0]!.id)
|
|
186
|
+
return
|
|
187
|
+
}
|
|
188
|
+
if (key.backspace || key.delete) {
|
|
189
|
+
setDraft(current => [...current].slice(0, -1).join(''))
|
|
190
|
+
return
|
|
191
|
+
}
|
|
192
|
+
if (key.return) {
|
|
193
|
+
if (draft.trim() !== '') answer(draft)
|
|
194
|
+
return
|
|
195
|
+
}
|
|
196
|
+
if (input !== '' && !key.ctrl && !key.meta) setDraft(current => current + input)
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
if (viewport.maxHeight === 0) return createElement(Box, { display: 'none' })
|
|
200
|
+
if (viewport.compact) {
|
|
201
|
+
return createElement(Text, { wrap: 'truncate-end' }, truncateColumns('provider login · esc cancel', viewport.contentColumns))
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const rows: Array<{ key: string; text: string; color?: string; bold?: boolean }> = []
|
|
205
|
+
if (phase === 'methods') {
|
|
206
|
+
if (error !== undefined) rows.push({ key: 'error', text: ` ${singleLineText(error)}`, color: inkColor(getPalette().error) })
|
|
207
|
+
props.row.methods.forEach((method, index) => {
|
|
208
|
+
rows.push({
|
|
209
|
+
key: method.id,
|
|
210
|
+
text: `${index === cursor ? '› ' : ' '}${displayText(method.label)}`,
|
|
211
|
+
color: inkColor(index === cursor ? getPalette().brandBright : getPalette().dim),
|
|
212
|
+
})
|
|
213
|
+
})
|
|
214
|
+
} else {
|
|
215
|
+
notices.forEach((notice, index) => {
|
|
216
|
+
rows.push({ key: `notice-${index}`, text: ` ${displayText(notice.message)}` })
|
|
217
|
+
if (notice.url !== undefined) rows.push({ key: `url-${index}`, text: ` ${displayText(notice.url)}`, color: inkColor(getPalette().brandBright) })
|
|
218
|
+
if (notice.code !== undefined) rows.push({ key: `code-${index}`, text: ` code ${displayText(notice.code)}`, color: inkColor(getPalette().success), bold: true })
|
|
219
|
+
})
|
|
220
|
+
if (prompt !== undefined) {
|
|
221
|
+
rows.push({ key: 'prompt', text: ` ${displayText(prompt.message)}`, color: inkColor(getPalette().brandBright) })
|
|
222
|
+
if (prompt.kind === 'select') {
|
|
223
|
+
prompt.options.forEach((option, index) => rows.push({
|
|
224
|
+
key: `option-${option.id}`,
|
|
225
|
+
text: `${index === promptCursor ? '› ' : ' '}${displayText(option.label)}${option.description === undefined ? '' : ` · ${displayText(option.description)}`}`,
|
|
226
|
+
color: inkColor(index === promptCursor ? getPalette().brandBright : getPalette().dim),
|
|
227
|
+
}))
|
|
228
|
+
} else {
|
|
229
|
+
const shown = prompt.kind === 'secret' ? '•'.repeat([...draft].length) : displayText(draft)
|
|
230
|
+
rows.push({ key: 'draft', text: ` ${shown}▏`, color: inkColor(getPalette().text) })
|
|
231
|
+
}
|
|
232
|
+
} else {
|
|
233
|
+
rows.push({ key: 'waiting', text: ' waiting for provider…', color: inkColor(getPalette().dim) })
|
|
234
|
+
}
|
|
235
|
+
if (copyState !== undefined) rows.push({ key: 'copy', text: ` ${singleLineText(copyState)}`, color: inkColor(copyState === 'copied' ? getPalette().success : getPalette().error) })
|
|
236
|
+
}
|
|
237
|
+
const visible = rows.slice(Math.max(0, rows.length - viewport.bodyRows))
|
|
238
|
+
const footer = phase === 'methods'
|
|
239
|
+
? '↑↓ choose · enter continue · esc/q back'
|
|
240
|
+
: 'enter answer · c copy URL/code · esc cancel login'
|
|
241
|
+
return createElement(
|
|
242
|
+
Box,
|
|
243
|
+
{ flexDirection: 'column', width: viewport.outerColumns, paddingX: 1, borderStyle: 'round', borderColor: inkColor(getPalette().brand) },
|
|
244
|
+
createElement(Text, { color: inkColor(getPalette().brand), bold: true, wrap: 'truncate-end' }, truncateColumns(`/model · login ${displayText(props.row.label)}`, viewport.contentColumns)),
|
|
245
|
+
...visible.map(row => createElement(Text, { key: row.key, color: row.color, bold: row.bold, wrap: 'truncate-end' }, truncateColumns(row.text, viewport.contentColumns))),
|
|
246
|
+
createElement(Text, { color: inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns(footer, viewport.contentColumns)),
|
|
247
|
+
)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function ProviderAuthorizationLogoutPanel({ row, confirm, done, back }: {
|
|
251
|
+
row: ProviderAuthorizationRow
|
|
252
|
+
confirm(row: ProviderAuthorizationRow): Promise<void>
|
|
253
|
+
done(): void
|
|
254
|
+
back(): void
|
|
255
|
+
}): ReactElement {
|
|
256
|
+
const stdout = useStdout().stdout
|
|
257
|
+
const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
|
|
258
|
+
const [busy, setBusy] = useState(false)
|
|
259
|
+
const [error, setError] = useState<string | undefined>(undefined)
|
|
260
|
+
useInput((input, key) => {
|
|
261
|
+
if (busy) return
|
|
262
|
+
if (key.escape || input === 'n' || input === 'N') {
|
|
263
|
+
back()
|
|
264
|
+
return
|
|
265
|
+
}
|
|
266
|
+
if (input !== 'y' && input !== 'Y') return
|
|
267
|
+
setBusy(true)
|
|
268
|
+
setError(undefined)
|
|
269
|
+
void confirm(row).then(done, (reason: unknown) => {
|
|
270
|
+
setBusy(false)
|
|
271
|
+
setError(reason instanceof Error ? reason.message : String(reason))
|
|
272
|
+
})
|
|
273
|
+
})
|
|
274
|
+
if (viewport.maxHeight === 0) return createElement(Box, { display: 'none' })
|
|
275
|
+
if (viewport.compact) return createElement(Text, { wrap: 'truncate-end' }, truncateColumns('y logout · n/esc back', viewport.contentColumns))
|
|
276
|
+
return createElement(
|
|
277
|
+
Box,
|
|
278
|
+
{ flexDirection: 'column', width: viewport.outerColumns, paddingX: 1, borderStyle: 'round', borderColor: inkColor(getPalette().warn) },
|
|
279
|
+
createElement(Text, { color: inkColor(getPalette().warn), bold: true, wrap: 'truncate-end' }, truncateColumns('/model · logout provider', viewport.contentColumns)),
|
|
280
|
+
createElement(Text, { wrap: 'truncate-end' }, truncateColumns(` remove ${displayText(row.label)} login record`, viewport.contentColumns)),
|
|
281
|
+
createElement(Text, { color: inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns(' provider endpoint and model configuration stay unchanged', viewport.contentColumns)),
|
|
282
|
+
error === undefined ? undefined : createElement(Text, { color: inkColor(getPalette().error), wrap: 'truncate-end' }, truncateColumns(` ${singleLineText(error)}`, viewport.contentColumns)),
|
|
283
|
+
createElement(Text, { color: inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns(busy ? 'working…' : 'y confirm · n/esc back', viewport.contentColumns)),
|
|
284
|
+
)
|
|
285
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/** Terminal adapter over the Harness provider-authorization and credential-record seams. */
|
|
2
|
+
|
|
3
|
+
import { spawn } from 'node:child_process'
|
|
4
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
5
|
+
import type {
|
|
6
|
+
AuthorizationEntry,
|
|
7
|
+
AuthorizationInteraction,
|
|
8
|
+
AuthorizationMethod,
|
|
9
|
+
AuthorizationStatus,
|
|
10
|
+
} from '@deepseek-ai/dsh-authorization'
|
|
11
|
+
import {
|
|
12
|
+
credentialKeyId,
|
|
13
|
+
credentialKeyScope,
|
|
14
|
+
type CredentialKey,
|
|
15
|
+
type CredentialRecordInfo,
|
|
16
|
+
} from '@deepseek-ai/dsh-credentials'
|
|
17
|
+
|
|
18
|
+
/** Record scope used by the upstream pi-ai adapter for provider logins. */
|
|
19
|
+
const PI_AI_RECORD_SCOPE = 'llm-pi-ai'
|
|
20
|
+
|
|
21
|
+
/** One provider login flow joined with its value-free stored-record facts. */
|
|
22
|
+
export interface ProviderAuthorizationRow {
|
|
23
|
+
readonly key: CredentialKey
|
|
24
|
+
readonly provider: string
|
|
25
|
+
readonly label: string
|
|
26
|
+
readonly methods: readonly AuthorizationMethod[]
|
|
27
|
+
readonly inFlight: boolean
|
|
28
|
+
readonly record: CredentialRecordInfo
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The provider-login directory plus non-fatal record lookup failures. */
|
|
32
|
+
export interface ProviderAuthorizationDirectory {
|
|
33
|
+
readonly rows: readonly ProviderAuthorizationRow[]
|
|
34
|
+
readonly failures: readonly string[]
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Load only model-provider flows; unrelated future authorization domains stay out of `/model`. */
|
|
38
|
+
export async function loadProviderAuthorizations(ctx: Context): Promise<ProviderAuthorizationDirectory> {
|
|
39
|
+
const authorization = ctx.get('authorization')
|
|
40
|
+
const credentials = ctx.get('credentials')
|
|
41
|
+
if (authorization === undefined || credentials === undefined) return { rows: [], failures: [] }
|
|
42
|
+
const entries = authorization.list().filter(entry => credentialKeyScope(entry.key) === PI_AI_RECORD_SCOPE)
|
|
43
|
+
const failures: string[] = []
|
|
44
|
+
const rows = await Promise.all(entries.map(async (entry): Promise<ProviderAuthorizationRow> => {
|
|
45
|
+
let record: CredentialRecordInfo
|
|
46
|
+
try {
|
|
47
|
+
record = await credentials.describeRecord(entry.key)
|
|
48
|
+
} catch (error: unknown) {
|
|
49
|
+
failures.push(`${entry.label}: ${error instanceof Error ? error.message : String(error)}`)
|
|
50
|
+
record = { configured: false, writable: false }
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
key: entry.key,
|
|
54
|
+
provider: credentialKeyId(entry.key),
|
|
55
|
+
label: entry.label,
|
|
56
|
+
methods: entry.methods,
|
|
57
|
+
inFlight: entry.inFlight,
|
|
58
|
+
record,
|
|
59
|
+
}
|
|
60
|
+
}))
|
|
61
|
+
return { rows, failures }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Subscribe to login settlement and credential-record changes. */
|
|
65
|
+
export function subscribeProviderAuthorizations(ctx: Context, listener: () => void): () => void {
|
|
66
|
+
const settled = ctx.on('authorization/settled', (key) => {
|
|
67
|
+
if (credentialKeyScope(key) === PI_AI_RECORD_SCOPE) listener()
|
|
68
|
+
})
|
|
69
|
+
const records = ctx.on('credentials/record-updated', (key) => {
|
|
70
|
+
if (credentialKeyScope(key) === PI_AI_RECORD_SCOPE) listener()
|
|
71
|
+
})
|
|
72
|
+
return () => {
|
|
73
|
+
settled()
|
|
74
|
+
records()
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Begin one provider login through the interaction surface owned by the caller. */
|
|
79
|
+
export async function beginProviderAuthorization(
|
|
80
|
+
ctx: Context,
|
|
81
|
+
row: Pick<ProviderAuthorizationRow, 'key'>,
|
|
82
|
+
method: string,
|
|
83
|
+
interaction: AuthorizationInteraction,
|
|
84
|
+
signal?: AbortSignal,
|
|
85
|
+
): Promise<AuthorizationStatus> {
|
|
86
|
+
const authorization = ctx.get('authorization')
|
|
87
|
+
if (authorization === undefined) throw new Error('provider login is unavailable in this profile')
|
|
88
|
+
return (await authorization.begin({ key: row.key, method, interaction, signal })).status
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Cancel the attempt currently serving this provider, if any. */
|
|
92
|
+
export function cancelProviderAuthorization(ctx: Context, key: CredentialKey): void {
|
|
93
|
+
ctx.get('authorization')?.cancel(key)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Remove an authorization record without changing the provider's settings profile. */
|
|
97
|
+
export async function logoutProviderAuthorization(ctx: Context, row: ProviderAuthorizationRow): Promise<void> {
|
|
98
|
+
const credentials = ctx.get('credentials')
|
|
99
|
+
if (credentials === undefined) throw new Error('credential storage is unavailable in this profile')
|
|
100
|
+
const current = await credentials.describeRecord(row.key)
|
|
101
|
+
if (!current.configured) return
|
|
102
|
+
if (!current.writable) throw new Error('this login record is read-only')
|
|
103
|
+
await credentials.deleteRecord(row.key)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Open an authorization URL with the platform default browser, without invoking a shell. */
|
|
107
|
+
export function openAuthorizationUrl(raw: string): boolean {
|
|
108
|
+
let url: URL
|
|
109
|
+
try {
|
|
110
|
+
url = new URL(raw)
|
|
111
|
+
} catch {
|
|
112
|
+
return false
|
|
113
|
+
}
|
|
114
|
+
if (url.protocol !== 'https:' && url.protocol !== 'http:') return false
|
|
115
|
+
const target = url.toString()
|
|
116
|
+
try {
|
|
117
|
+
const child = process.platform === 'win32'
|
|
118
|
+
? spawn('explorer.exe', [target], { detached: true, stdio: 'ignore' })
|
|
119
|
+
: process.platform === 'darwin'
|
|
120
|
+
? spawn('/usr/bin/open', [target], { detached: true, stdio: 'ignore' })
|
|
121
|
+
: spawn('xdg-open', [target], { detached: true, stdio: 'ignore' })
|
|
122
|
+
child.once('error', () => {})
|
|
123
|
+
child.unref()
|
|
124
|
+
return true
|
|
125
|
+
} catch {
|
|
126
|
+
return false
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Compact value-free status for the provider list. */
|
|
131
|
+
export function providerAuthorizationStatus(row: ProviderAuthorizationRow | undefined): string {
|
|
132
|
+
if (row === undefined) return 'login unavailable'
|
|
133
|
+
if (row.inFlight) return 'login in progress'
|
|
134
|
+
if (!row.record.configured) return 'not logged in'
|
|
135
|
+
return row.record.kind === 'grant' ? 'OAuth' : 'interactive API key'
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Find a provider's login flow from a previously loaded directory. */
|
|
139
|
+
export function authorizationForProvider(
|
|
140
|
+
directory: ProviderAuthorizationDirectory | undefined,
|
|
141
|
+
provider: string,
|
|
142
|
+
): ProviderAuthorizationRow | undefined {
|
|
143
|
+
return directory?.rows.find(row => row.provider === provider)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Preserve the upstream entry type in declarations without leaking service internals into the TUI. */
|
|
147
|
+
export type { AuthorizationEntry }
|
package/src/index.ts
CHANGED
|
@@ -22,6 +22,7 @@ import type { Agent, AgentHandle, ModelSelection, ModelSelectionRef } from '@dee
|
|
|
22
22
|
import type {} from '@deepseek-ai/dsh-agent-default-model'
|
|
23
23
|
import type {} from '@deepseek-ai/dsh-attachment'
|
|
24
24
|
import { createUserMessage, MessageId, type ContentBlock, type ImageBlock } from '@deepseek-ai/dsh-llm'
|
|
25
|
+
import type { JobSnapshot } from '@deepseek-ai/dsh-jobs'
|
|
25
26
|
import { SessionId, type Session, type SessionEvent, type SessionHeader, type UserMessage } from '@deepseek-ai/dsh-session'
|
|
26
27
|
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
|
|
27
28
|
// Type-only: carries the ctx.sessionTitle service merge for /title.
|
|
@@ -52,8 +53,16 @@ import { HISTORY_MAX_ENTRIES, parseHistoryFile, serializeHistoryList } from './h
|
|
|
52
53
|
import { watchSkills, type SkillsView } from './skills.ts'
|
|
53
54
|
import { toolArgumentsPreview } from './render/tool-preview.ts'
|
|
54
55
|
import { buildExportMarkdown } from './render/export.ts'
|
|
55
|
-
import { saveImagePaths } from './attachments.ts'
|
|
56
|
+
import { inspectImagePaths, saveImagePaths } from './attachments.ts'
|
|
56
57
|
import { copyText, latestAssistantText } from './editor.ts'
|
|
58
|
+
import {
|
|
59
|
+
beginProviderAuthorization,
|
|
60
|
+
cancelProviderAuthorization,
|
|
61
|
+
loadProviderAuthorizations,
|
|
62
|
+
logoutProviderAuthorization,
|
|
63
|
+
openAuthorizationUrl,
|
|
64
|
+
subscribeProviderAuthorizations,
|
|
65
|
+
} from './authorization.ts'
|
|
57
66
|
import { selectForkSeed } from './fork.ts'
|
|
58
67
|
import { buildReviewPrompt, loadGitDiff } from './git-workflow.ts'
|
|
59
68
|
import type { TuiStartup } from './startup.ts'
|
|
@@ -118,19 +127,6 @@ function fail(io: TuiIo, error: unknown): void {
|
|
|
118
127
|
io.exit(1)
|
|
119
128
|
}
|
|
120
129
|
|
|
121
|
-
/** The `ctx.jobs` registry face (dsh-jobs-local behind the base patch row). */
|
|
122
|
-
interface JobsServiceLike {
|
|
123
|
-
list(caller?: Agent): ReadonlyArray<{
|
|
124
|
-
id: string
|
|
125
|
-
kind: string
|
|
126
|
-
label: string
|
|
127
|
-
status: 'running' | 'stopping' | 'completed' | 'killed' | 'failed'
|
|
128
|
-
detail?: string
|
|
129
|
-
startedAt: number
|
|
130
|
-
finishedAt?: number
|
|
131
|
-
}>
|
|
132
|
-
}
|
|
133
|
-
|
|
134
130
|
/**
|
|
135
131
|
* Snapshot caller-visible background jobs for the /jobs panel. Jobs the agent
|
|
136
132
|
* started through run_in_background are fenced by their owner, so the CURRENT
|
|
@@ -142,10 +138,10 @@ interface JobsServiceLike {
|
|
|
142
138
|
* @returns job rows in registration order; never throws.
|
|
143
139
|
*/
|
|
144
140
|
function listJobs(ctx: Context, caller: Agent | undefined): readonly import('./kernel-panels.ts').JobRow[] {
|
|
145
|
-
const jobs =
|
|
141
|
+
const jobs = ctx.get('jobs')
|
|
146
142
|
if (jobs === undefined) return []
|
|
147
143
|
try {
|
|
148
|
-
return jobs.list(caller).map(job => ({
|
|
144
|
+
return jobs.list(caller).map((job: JobSnapshot) => ({
|
|
149
145
|
id: job.id,
|
|
150
146
|
kind: job.kind,
|
|
151
147
|
label: job.label,
|
|
@@ -884,8 +880,8 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
884
880
|
}
|
|
885
881
|
|
|
886
882
|
/** Dispatch one submitted line: slash commands to the registry, other text to the agent. */
|
|
887
|
-
const dispatch = (text: string): void => {
|
|
888
|
-
send(text, 'followup')
|
|
883
|
+
const dispatch = (text: string, images: readonly ImageBlock[] = []): void => {
|
|
884
|
+
send(text, 'followup', images)
|
|
889
885
|
}
|
|
890
886
|
|
|
891
887
|
/**
|
|
@@ -893,8 +889,8 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
893
889
|
* boundary (the inbox delivers between steps); an idle driver just starts
|
|
894
890
|
* a turn, so this doubles as the busy-state submit path.
|
|
895
891
|
*/
|
|
896
|
-
const steer = (text: string): void => {
|
|
897
|
-
send(text, 'steer')
|
|
892
|
+
const steer = (text: string, images: readonly ImageBlock[] = []): void => {
|
|
893
|
+
send(text, 'steer', images)
|
|
898
894
|
}
|
|
899
895
|
|
|
900
896
|
/** Interrupt the running turn (Esc); true when a turn was actually cancelled. */
|
|
@@ -1398,7 +1394,18 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1398
1394
|
saveModelProviderConfiguration: (target, configuration) => saveProviderConfiguration(ctx, target, configuration),
|
|
1399
1395
|
unsetModelProviderCredential: target => unsetProviderCredential(ctx, target),
|
|
1400
1396
|
removeModelProvider: target => removeProviderSettings(ctx, target),
|
|
1397
|
+
loadProviderAuthorizations: () => loadProviderAuthorizations(ctx),
|
|
1398
|
+
subscribeProviderAuthorizations: listener => subscribeProviderAuthorizations(ctx, listener),
|
|
1399
|
+
beginProviderAuthorization: (row, method, interaction, signal) => (
|
|
1400
|
+
beginProviderAuthorization(ctx, row, method, interaction, signal)
|
|
1401
|
+
),
|
|
1402
|
+
cancelProviderAuthorization: row => cancelProviderAuthorization(ctx, row.key),
|
|
1403
|
+
logoutProviderAuthorization: row => logoutProviderAuthorization(ctx, row),
|
|
1404
|
+
openAuthorizationUrl,
|
|
1405
|
+
copyTextValue: copyText,
|
|
1401
1406
|
loadMentions: (query: string, signal?: AbortSignal) => mentions.candidates(query, signal),
|
|
1407
|
+
inspectImages: paths => inspectImagePaths(paths, ctx.get('attachments'), session?.header.cwd ?? cwd),
|
|
1408
|
+
prepareImages: (paths, signal) => saveImagePaths(paths, ctx.get('attachments'), signal),
|
|
1402
1409
|
cyclePermission,
|
|
1403
1410
|
setPermission: setPermissionAction,
|
|
1404
1411
|
selectModel,
|
|
@@ -1449,8 +1456,14 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1449
1456
|
// Startup prompt/images use the same durable delivery path as composer
|
|
1450
1457
|
// submissions. Image bytes are committed before the user/message event.
|
|
1451
1458
|
if (startup.prompt !== undefined || (startup.images?.length ?? 0) > 0) {
|
|
1459
|
+
if ((startup.images?.length ?? 0) > 0) {
|
|
1460
|
+
bridge.notify(`processing ${startup.images!.length} startup image${startup.images!.length === 1 ? '' : 's'}…`)
|
|
1461
|
+
}
|
|
1452
1462
|
void saveImagePaths(startup.images ?? [], ctx.get('attachments')).then(
|
|
1453
|
-
images =>
|
|
1463
|
+
images => {
|
|
1464
|
+
if (images.length > 0) bridge.notify(`${images.length} startup image${images.length === 1 ? '' : 's'} attached`)
|
|
1465
|
+
send(startup.prompt ?? '', 'followup', images)
|
|
1466
|
+
},
|
|
1454
1467
|
(error: unknown) => bridge.notify(`initial prompt failed: ${error instanceof Error ? error.message : String(error)}`, 'error'),
|
|
1455
1468
|
)
|
|
1456
1469
|
}
|