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.
@@ -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 { basename } from 'node:path'
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 = (ctx as unknown as { get(name: string): unknown }).get('jobs') as JobsServiceLike | undefined
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 => send(startup.prompt ?? '', 'followup', 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
  }