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.
Files changed (67) hide show
  1. package/README.en.md +278 -249
  2. package/README.md +131 -102
  3. package/bin/deepseek.mjs +100 -6
  4. package/cordis.patch.yml +36 -1
  5. package/lib/index.mjs +3055 -819
  6. package/lib/startup.mjs +21 -11
  7. package/lib/{theme-BEi4i_aN.mjs → theme-DCT8Y2xf.mjs} +13 -9
  8. package/lib/types/app.d.ts +84 -16
  9. package/lib/types/attachments.d.ts +20 -0
  10. package/lib/types/authorization-panel.d.ts +22 -0
  11. package/lib/types/authorization.d.ts +36 -0
  12. package/lib/types/editor.d.ts +6 -0
  13. package/lib/types/fork.d.ts +8 -0
  14. package/lib/types/git-workflow.d.ts +23 -0
  15. package/lib/types/index.d.ts +6 -0
  16. package/lib/types/kernel-panels.d.ts +39 -0
  17. package/lib/types/keyboard.d.ts +41 -0
  18. package/lib/types/mentions.d.ts +30 -38
  19. package/lib/types/models.d.ts +3 -1
  20. package/lib/types/permissions.d.ts +4 -14
  21. package/lib/types/presets.d.ts +5 -20
  22. package/lib/types/provider-settings.d.ts +16 -0
  23. package/lib/types/render/animations.d.ts +10 -39
  24. package/lib/types/render/editor.d.ts +137 -0
  25. package/lib/types/render/export.d.ts +1 -1
  26. package/lib/types/render/lines.d.ts +6 -2
  27. package/lib/types/render/markdown.d.ts +3 -1
  28. package/lib/types/render/projection.d.ts +29 -3
  29. package/lib/types/render/status.d.ts +6 -13
  30. package/lib/types/session-directory.d.ts +1 -3
  31. package/lib/types/startup.d.ts +14 -11
  32. package/lib/types/store.d.ts +11 -9
  33. package/lib/types/subagents.d.ts +3 -3
  34. package/lib/types/theme.d.ts +14 -1
  35. package/lib/types/version.d.ts +15 -2
  36. package/package.json +159 -141
  37. package/src/app.ts +1490 -663
  38. package/src/attachments.ts +128 -0
  39. package/src/authorization-panel.ts +285 -0
  40. package/src/authorization.ts +147 -0
  41. package/src/editor.ts +51 -0
  42. package/src/fork.ts +31 -0
  43. package/src/git-workflow.ts +87 -0
  44. package/src/index.ts +1523 -1374
  45. package/src/internals.ts +14 -1
  46. package/src/kernel-panels.ts +914 -798
  47. package/src/keyboard.ts +126 -0
  48. package/src/mentions.ts +78 -117
  49. package/src/models.ts +20 -14
  50. package/src/permissions.ts +5 -13
  51. package/src/presets.ts +6 -22
  52. package/src/provider-settings.ts +95 -1
  53. package/src/render/animations.ts +420 -450
  54. package/src/render/editor.ts +398 -0
  55. package/src/render/export.ts +79 -79
  56. package/src/render/lines.ts +342 -236
  57. package/src/render/markdown.ts +99 -26
  58. package/src/render/projection.ts +106 -19
  59. package/src/render/status.ts +713 -650
  60. package/src/render/text.ts +150 -150
  61. package/src/render/tool-detail.ts +3 -1
  62. package/src/session-directory.ts +4 -4
  63. package/src/startup.ts +136 -119
  64. package/src/store.ts +23 -11
  65. package/src/subagents.ts +13 -5
  66. package/src/theme.ts +214 -206
  67. 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
+ }