dsh-code 0.9.1 → 1.0.1
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 -249
- package/README.md +131 -102
- package/bin/deepseek.mjs +100 -6
- package/cordis.patch.yml +36 -1
- package/lib/index.mjs +3055 -819
- package/lib/startup.mjs +21 -11
- package/lib/{theme-BEi4i_aN.mjs → theme-DCT8Y2xf.mjs} +13 -9
- package/lib/types/app.d.ts +84 -16
- package/lib/types/attachments.d.ts +20 -0
- package/lib/types/authorization-panel.d.ts +22 -0
- package/lib/types/authorization.d.ts +36 -0
- package/lib/types/editor.d.ts +6 -0
- package/lib/types/fork.d.ts +8 -0
- package/lib/types/git-workflow.d.ts +23 -0
- package/lib/types/index.d.ts +6 -0
- package/lib/types/kernel-panels.d.ts +39 -0
- package/lib/types/keyboard.d.ts +41 -0
- package/lib/types/mentions.d.ts +30 -38
- package/lib/types/models.d.ts +3 -1
- package/lib/types/permissions.d.ts +4 -14
- package/lib/types/presets.d.ts +5 -20
- package/lib/types/provider-settings.d.ts +16 -0
- package/lib/types/render/animations.d.ts +10 -39
- package/lib/types/render/editor.d.ts +137 -0
- package/lib/types/render/export.d.ts +1 -1
- package/lib/types/render/lines.d.ts +6 -2
- package/lib/types/render/markdown.d.ts +3 -1
- package/lib/types/render/projection.d.ts +29 -3
- package/lib/types/render/status.d.ts +6 -13
- package/lib/types/session-directory.d.ts +1 -3
- package/lib/types/startup.d.ts +14 -11
- package/lib/types/store.d.ts +11 -9
- package/lib/types/subagents.d.ts +3 -3
- package/lib/types/theme.d.ts +14 -1
- package/lib/types/version.d.ts +15 -2
- package/package.json +159 -141
- package/src/app.ts +1490 -663
- package/src/attachments.ts +128 -0
- package/src/authorization-panel.ts +285 -0
- package/src/authorization.ts +147 -0
- package/src/editor.ts +51 -0
- package/src/fork.ts +31 -0
- package/src/git-workflow.ts +87 -0
- package/src/index.ts +1523 -1374
- package/src/internals.ts +14 -1
- package/src/kernel-panels.ts +914 -798
- package/src/keyboard.ts +126 -0
- package/src/mentions.ts +78 -117
- package/src/models.ts +20 -14
- package/src/permissions.ts +5 -13
- package/src/presets.ts +6 -22
- package/src/provider-settings.ts +95 -1
- package/src/render/animations.ts +420 -450
- package/src/render/editor.ts +398 -0
- package/src/render/export.ts +79 -79
- package/src/render/lines.ts +342 -236
- package/src/render/markdown.ts +99 -26
- package/src/render/projection.ts +106 -19
- package/src/render/status.ts +713 -650
- package/src/render/text.ts +150 -150
- package/src/render/tool-detail.ts +3 -1
- package/src/session-directory.ts +4 -4
- package/src/startup.ts +136 -119
- package/src/store.ts +23 -11
- package/src/subagents.ts +13 -5
- package/src/theme.ts +214 -206
- package/src/version.ts +58 -1
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/** Terminal image-file adapter over the Harness durable attachment service. */
|
|
2
|
+
|
|
3
|
+
import { open, readFile, stat } from 'node:fs/promises'
|
|
4
|
+
import { fileURLToPath } from 'node:url'
|
|
5
|
+
import { basename, extname, isAbsolute, resolve } from 'node:path'
|
|
6
|
+
import type { AttachmentStore, ImageMediaType, SaveImageAttachment } from '@deepseek-ai/dsh-attachment'
|
|
7
|
+
import type { ImageBlock } from '@deepseek-ai/dsh-llm'
|
|
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
|
+
|
|
19
|
+
/** Detect the supported encoded raster formats from bytes, never from a path suffix. */
|
|
20
|
+
export function detectImageMediaType(data: Uint8Array): ImageMediaType | undefined {
|
|
21
|
+
if (data.length >= 8 && data[0] === 0x89 && data[1] === 0x50 && data[2] === 0x4e && data[3] === 0x47
|
|
22
|
+
&& data[4] === 0x0d && data[5] === 0x0a && data[6] === 0x1a && data[7] === 0x0a) return 'image/png'
|
|
23
|
+
if (data.length >= 3 && data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff) return 'image/jpeg'
|
|
24
|
+
if (data.length >= 6) {
|
|
25
|
+
const signature = String.fromCharCode(...data.subarray(0, 6))
|
|
26
|
+
if (signature === 'GIF87a' || signature === 'GIF89a') return 'image/gif'
|
|
27
|
+
}
|
|
28
|
+
if (data.length >= 12
|
|
29
|
+
&& String.fromCharCode(...data.subarray(0, 4)) === 'RIFF'
|
|
30
|
+
&& String.fromCharCode(...data.subarray(8, 12)) === 'WEBP') return 'image/webp'
|
|
31
|
+
return undefined
|
|
32
|
+
}
|
|
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
|
+
|
|
107
|
+
/** Read, validate, and persist an ordered image path list as model content blocks. */
|
|
108
|
+
export async function saveImagePaths(
|
|
109
|
+
paths: readonly string[],
|
|
110
|
+
attachments: AttachmentStore | undefined,
|
|
111
|
+
): Promise<readonly ImageBlock[]> {
|
|
112
|
+
if (paths.length === 0) return []
|
|
113
|
+
if (attachments === undefined) throw new Error('image attachments are unavailable in this profile')
|
|
114
|
+
const inputs: SaveImageAttachment[] = []
|
|
115
|
+
for (const path of paths) {
|
|
116
|
+
let data: Uint8Array
|
|
117
|
+
try {
|
|
118
|
+
data = await readFile(path)
|
|
119
|
+
} catch (error: unknown) {
|
|
120
|
+
throw new Error(`cannot read image "${path}": ${error instanceof Error ? error.message : String(error)}`)
|
|
121
|
+
}
|
|
122
|
+
const mediaType = detectImageMediaType(data)
|
|
123
|
+
if (mediaType === undefined) throw new Error(`unsupported image file "${path}" (expected PNG, JPEG, WebP, or GIF)`)
|
|
124
|
+
inputs.push({ data, mediaType, name: basename(path) })
|
|
125
|
+
}
|
|
126
|
+
const refs = await attachments.saveImages(inputs)
|
|
127
|
+
return refs.map(attachment => ({ type: 'image', attachment }))
|
|
128
|
+
}
|
|
@@ -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/editor.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/** Host editor and clipboard adapters used by the terminal surface. */
|
|
2
|
+
|
|
3
|
+
import { spawn } from 'node:child_process'
|
|
4
|
+
import type { TranscriptView } from './render/projection.ts'
|
|
5
|
+
|
|
6
|
+
function waitForProcess(command: string, args: readonly string[], input?: string): Promise<void> {
|
|
7
|
+
return new Promise((resolve, reject) => {
|
|
8
|
+
const child = spawn(command, [...args], {
|
|
9
|
+
stdio: input === undefined ? 'inherit' : ['pipe', 'ignore', 'pipe'],
|
|
10
|
+
windowsHide: true,
|
|
11
|
+
})
|
|
12
|
+
let stderr = ''
|
|
13
|
+
child.stderr?.on('data', chunk => { stderr += String(chunk) })
|
|
14
|
+
child.once('error', reject)
|
|
15
|
+
child.once('exit', code => {
|
|
16
|
+
if (code === 0) resolve()
|
|
17
|
+
else reject(new Error(`${command} exited with code ${String(code)}${stderr.trim() === '' ? '' : `: ${stderr.trim()}`}`))
|
|
18
|
+
})
|
|
19
|
+
if (input !== undefined) child.stdin?.end(input)
|
|
20
|
+
})
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Copy UTF-8 text through the platform clipboard command. */
|
|
24
|
+
export async function copyText(text: string): Promise<void> {
|
|
25
|
+
if (process.platform === 'win32') {
|
|
26
|
+
// Windows PowerShell 5 reads redirected stdin using the active console
|
|
27
|
+
// code page by default. Node writes UTF-8, so CJK copied through `$input`
|
|
28
|
+
// became mojibake. Set the pipe encoding before reading it.
|
|
29
|
+
await waitForProcess('powershell.exe', [
|
|
30
|
+
'-NoProfile',
|
|
31
|
+
'-NonInteractive',
|
|
32
|
+
'-Command',
|
|
33
|
+
'[Console]::InputEncoding = [System.Text.UTF8Encoding]::new($false); Set-Clipboard -Value ([Console]::In.ReadToEnd())',
|
|
34
|
+
], text)
|
|
35
|
+
return
|
|
36
|
+
}
|
|
37
|
+
if (process.platform === 'darwin') {
|
|
38
|
+
await waitForProcess('pbcopy', [], text)
|
|
39
|
+
return
|
|
40
|
+
}
|
|
41
|
+
await waitForProcess('xclip', ['-selection', 'clipboard'], text)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Latest complete assistant text, excluding streaming and reasoning. */
|
|
45
|
+
export function latestAssistantText(view: TranscriptView): string | undefined {
|
|
46
|
+
for (let index = view.entries.length - 1; index >= 0; index -= 1) {
|
|
47
|
+
const entry = view.entries[index]
|
|
48
|
+
if (entry?.kind === 'assistant' && entry.text !== '') return entry.text
|
|
49
|
+
}
|
|
50
|
+
return undefined
|
|
51
|
+
}
|
package/src/fork.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/** Pure session-fork boundary policy shared by the TUI command and tests. */
|
|
2
|
+
|
|
3
|
+
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
|
4
|
+
|
|
5
|
+
export interface ForkSeed {
|
|
6
|
+
readonly boundarySeq: number
|
|
7
|
+
readonly events: readonly SessionEvent[]
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Select a completed turn and trailing between-turn metadata. */
|
|
11
|
+
export function selectForkSeed(events: readonly SessionEvent[], atSeq?: number): ForkSeed {
|
|
12
|
+
if (atSeq !== undefined && (!Number.isSafeInteger(atSeq) || atSeq < 0)) {
|
|
13
|
+
throw new Error('fork event sequence must be a non-negative integer')
|
|
14
|
+
}
|
|
15
|
+
const lastSeq = events.at(-1)?.seq ?? -1
|
|
16
|
+
const anchored = atSeq === undefined
|
|
17
|
+
? undefined
|
|
18
|
+
: events.find(event => event.type === 'turn/end' && event.seq >= atSeq)
|
|
19
|
+
const boundary = anchored
|
|
20
|
+
?? (atSeq === undefined || atSeq > lastSeq
|
|
21
|
+
? events.findLast(event => event.type === 'turn/end')
|
|
22
|
+
: undefined)
|
|
23
|
+
if (boundary === undefined) {
|
|
24
|
+
throw new Error(atSeq !== undefined && atSeq <= lastSeq
|
|
25
|
+
? `the turn containing event ${atSeq} has not completed`
|
|
26
|
+
: 'this session has no completed turn to fork from')
|
|
27
|
+
}
|
|
28
|
+
let cut = events.indexOf(boundary) + 1
|
|
29
|
+
while (cut < events.length && events[cut]?.type !== 'turn/start') cut += 1
|
|
30
|
+
return { boundarySeq: boundary.seq, events: events.slice(0, cut) }
|
|
31
|
+
}
|