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,126 @@
1
+ /**
2
+ * Keyboard enhancement protocol (Codex `keyboard_modes` parity) and the
3
+ * kitty CSI-u normalization layer.
4
+ *
5
+ * The TUI pushes the kitty keyboard protocol with DISAMBIGUATE_ESCAPE_CODES
6
+ * and REPORT_ALTERNATE_KEYS (flags 1|4 = `\x1b[>5u`). Event types are
7
+ * deliberately NOT requested: Ink 5's parser cannot decode the
8
+ * `:event-type` suffix, and repeat/release reporting buys this surface
9
+ * nothing.
10
+ *
11
+ * Ink 5 also cannot parse most CSI-u forms at all — they fall through its
12
+ * regex as unnamed sequences and get INSERTED AS DRAFT TEXT. The composer's
13
+ * stdin read patch therefore rewrites every CSI-u form it can decode back
14
+ * to the legacy byte or canonical sequence the existing key handling
15
+ * already understands, before Ink ever parses the chunk.
16
+ * @module @deepseek-ai/dsh-code/keyboard
17
+ */
18
+
19
+ /** Push keyboard enhancement (modifyOtherKeys off, kitty flags 1|4). */
20
+ export const KEYBOARD_ENHANCE_ENABLE = '\x1b[>4;0m\x1b[>5u'
21
+
22
+ /** Pop the enhancement stack and reset modifyOtherKeys (exit path). */
23
+ export const KEYBOARD_ENHANCE_DISABLE = '\x1b[<u\x1b[>4;0m'
24
+
25
+ /** Enable bracketed paste reporting. */
26
+ export const BRACKETED_PASTE_ENABLE = '\x1b[?2004h'
27
+
28
+ /** Disable bracketed paste reporting. */
29
+ export const BRACKETED_PASTE_DISABLE = '\x1b[?2004l'
30
+
31
+ /** Bracketed paste markers as Ink delivers them (it strips the leading ESC). */
32
+ export const PASTE_START_MARKER = '[200~'
33
+ export const PASTE_END_MARKER = '[201~'
34
+
35
+ /**
36
+ * Remove bracketed paste markers from one input chunk. Panel drafts accept raw
37
+ * `input` text, where an unhandled paste would otherwise persist the literal
38
+ * "[200~"/"[201~" markers Ink leaves after stripping the ESC byte.
39
+ */
40
+ export function stripPasteMarkers(text: string): string {
41
+ return text
42
+ .replaceAll(`\x1b${PASTE_START_MARKER}`, '')
43
+ .replaceAll(`\x1b${PASTE_END_MARKER}`, '')
44
+ .replaceAll(PASTE_START_MARKER, '')
45
+ .replaceAll(PASTE_END_MARKER, '')
46
+ }
47
+
48
+ /** One decoded CSI-u keypress: key code, 1-based modifier param, alternate code. */
49
+ interface CsiUKey {
50
+ code: number
51
+ modifiers: number
52
+ alternate?: number
53
+ }
54
+
55
+ /** Match one CSI-u sequence (code, optional ;modifiers, then :event or ;alternate). */
56
+ const CSI_U_SOURCE = '\x1b\\[(\\d+)(?:;(\\d+))?(?:[:;](\\d+))?u'
57
+
58
+ /** Legacy equivalent for one decoded CSI-u key, or undefined to pass through. */
59
+ function legacyForKey(key: CsiUKey): string | undefined {
60
+ const bits = Math.max(0, key.modifiers - 1)
61
+ const shift = (bits & 1) !== 0
62
+ const alt = (bits & 2) !== 0
63
+ const ctrl = (bits & 4) !== 0
64
+ if (key.code === 13) {
65
+ // Modified Enter has no dedicated composer behavior. Preserve the legacy
66
+ // Ctrl/Alt bytes and collapse every other form to ordinary Enter.
67
+ if (ctrl) return '\n'
68
+ if (alt) return '\x1b\r'
69
+ return '\r'
70
+ }
71
+ if (key.code === 27) return '\x1b'
72
+ if (key.code === 9) return shift ? '\x1b[Z' : '\t'
73
+ if (key.code === 127) return alt || ctrl ? '\x1b\x7f' : '\x7f'
74
+ // Kitty disambiguate mode reports the six legacy functional keys as CSI u
75
+ // codes 1-6 (Home, Insert, Delete, End, PageUp, PageDown). Ink 5 cannot
76
+ // parse these forms and would insert literal "[3u" text into the draft, so
77
+ // rewrite them to the legacy sequences the input layer already annotates.
78
+ // The modifier parameter passes through: kitty and xterm share the same
79
+ // 1+bit-field encoding (shift 2, alt 3, ctrl 5, ...). Lock-key bits are
80
+ // dropped because the legacy sequences cannot express them.
81
+ if (key.code >= 1 && key.code <= 6) {
82
+ const mask = (shift ? 1 : 0) + (alt ? 2 : 0) + (ctrl ? 4 : 0)
83
+ const mods = mask === 0 ? '' : `;${mask + 1}`
84
+ if (key.code === 1) return mods === '' ? '\x1b[H' : `\x1b[1${mods}H`
85
+ if (key.code === 4) return mods === '' ? '\x1b[F' : `\x1b[1${mods}F`
86
+ return `\x1b[${key.code}${mods}~`
87
+ }
88
+ if (key.code >= 97 && key.code <= 122) {
89
+ const letter = String.fromCodePoint(key.code)
90
+ if (ctrl) return String.fromCodePoint(key.code - 96)
91
+ if (alt) return '\x1b' + letter
92
+ if (shift) return String.fromCodePoint(key.alternate ?? key.code - 32)
93
+ return letter
94
+ }
95
+ if (key.code >= 65 && key.code <= 90) {
96
+ if (ctrl) return String.fromCodePoint(key.code + 32 - 96)
97
+ if (alt) return '\x1b' + String.fromCodePoint(key.code + 32)
98
+ return String.fromCodePoint(key.code)
99
+ }
100
+ if (key.code >= 32 && key.code <= 126 && key.alternate !== undefined) {
101
+ const base = key.alternate >= 97 && key.alternate <= 122 ? key.alternate : key.code
102
+ if (ctrl && base - 96 >= 1 && base - 96 <= 26) return String.fromCodePoint(base - 96)
103
+ if (alt) return '\x1b' + String.fromCodePoint(key.alternate)
104
+ return String.fromCodePoint(key.alternate)
105
+ }
106
+ return undefined
107
+ }
108
+
109
+ /**
110
+ * Rewrite every decodable kitty CSI-u sequence in one stdin chunk to the
111
+ * legacy form the input layer already handles. Undecodable or non-key
112
+ * sequences pass through untouched, so terminals without the protocol are
113
+ * unaffected.
114
+ */
115
+ export function normalizeKeyboardChunk(chunk: string): string {
116
+ if (!chunk.includes('\x1b[') || !chunk.includes('u')) return chunk
117
+ const pattern = new RegExp(CSI_U_SOURCE, 'g')
118
+ return chunk.replace(pattern, (whole, code: string, mods?: string, third?: string) => {
119
+ const legacy = legacyForKey({
120
+ code: Number.parseInt(code, 10),
121
+ modifiers: mods === undefined || mods === '' ? 1 : Math.max(1, Number.parseInt(mods, 10)),
122
+ alternate: third !== undefined && third !== '' ? Number.parseInt(third, 10) : undefined,
123
+ })
124
+ return legacy ?? whole
125
+ })
126
+ }
package/src/mentions.ts CHANGED
@@ -1,25 +1,34 @@
1
1
  /**
2
- * Workspace @mention support: file and directory candidates from a bounded
3
- * async scan of the session cwd, session candidates from the opt-in
4
- * `sessionReferenceResolver` service, and submission preparation through its
5
- * `prepare()` API. Picked session mentions land as canonical
6
- * `@[label](dsh-session:…)` tokens; on submit the text is parsed back into
7
- * readable `@label` text plus structured references, snapshots are injected
8
- * via `agent.inject()` before the readable message wakes the driver
2
+ * Workspace @mention support: file and directory candidates from the
3
+ * `fileReferences` service (dsh-file-reference-local), session candidates
4
+ * from the opt-in `sessionReferenceResolver` service, and submission
5
+ * preparation through its `prepare()` API. Picked session mentions land as
6
+ * canonical `@[label](dsh-session:…)` tokens; on submit the text is parsed
7
+ * back into readable `@label` text plus structured references, snapshots are
8
+ * injected via `agent.inject()` before the readable message wakes the driver
9
9
  * (`followup` idle, `steer` running) — exactly the upstream README's wiring.
10
10
  *
11
- * Harness exposes no workspace-file mention service (only session references
12
- * plus a post-hoc produced-file linker), so the file index is the lightweight
13
- * bounded scan below, kept deliberately smaller than Codex's streaming
14
- * gitignore-aware walker.
11
+ * File discovery lives entirely in the Harness service (per-agent bounded
12
+ * index, `@dir/` listing, symlink guards, tool/result invalidation); this
13
+ * module only maps candidates to menu rows and never re-implements scanning.
14
+ * The service is agent-scoped (the agent supplies the session cwd and the
15
+ * cache key), so before the first session creates an agent the SAME official
16
+ * search class runs against the launch cwd — @ file completion works on a
17
+ * bare launch, model- and session-independent, and the agent-scoped service
18
+ * takes over once a session exists.
15
19
  *
16
20
  * @module @deepseek-ai/dsh-code/mentions
17
21
  */
18
22
 
19
- import { readdir } from 'node:fs/promises'
20
- import { join } from 'node:path'
21
23
  import type { Context } from '@deepseek-ai/cordis'
22
24
  import type { Agent } from '@deepseek-ai/dsh-agent'
25
+ import { isAbsolute, resolve } from 'node:path'
26
+ import {
27
+ DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES,
28
+ DEFAULT_FILE_SEARCH_MAX_ENTRIES,
29
+ DEFAULT_FILE_SEARCH_MAX_RESULTS,
30
+ WorkspaceFileSearch,
31
+ } from '@deepseek-ai/dsh-file-reference-local'
23
32
  import {
24
33
  formatSessionReferenceMention,
25
34
  parseSessionReferenceText,
@@ -30,14 +39,6 @@ import {
30
39
  /** Parsed submission text: readable text plus structured references. */
31
40
  type ParsedSessionReferenceText = ReturnType<typeof parseSessionReferenceText>
32
41
 
33
- /** One filesystem entry the @ menu can complete. */
34
- export interface FileCandidate {
35
- /** Workspace-relative path with forward slashes. */
36
- path: string
37
- /** Entry kind; directories insert with a trailing slash. */
38
- kind: 'file' | 'directory'
39
- }
40
-
41
42
  /** One merged menu candidate (files and sessions, already ranked). */
42
43
  export interface MentionCandidate {
43
44
  /** Text inserted after the `@` (directories carry a trailing slash). */
@@ -46,6 +47,8 @@ export interface MentionCandidate {
46
47
  description: string
47
48
  /** Origin kind for icon/coloring decisions. */
48
49
  kind: 'file' | 'directory' | 'session'
50
+ /** Absolute path for file candidates; never rendered or persisted directly. */
51
+ path?: string
49
52
  }
50
53
 
51
54
  /** Prepared submission: readable content plus optional injected context. */
@@ -58,76 +61,22 @@ export interface PreparedMention {
58
61
  additionalContext?: import('@deepseek-ai/dsh-session').UserMessage
59
62
  }
60
63
 
61
- /** Directories never entered and files never listed during the scan. */
62
- const SKIP_DIRS = new Set(['.git', 'node_modules', 'lib', 'dist', 'out', '.omc', 'coverage'])
63
- const MAX_FILES = 4000
64
- const MAX_DEPTH = 12
65
- /** Empty-query default rows: proves the index exists without typing (Codex's
66
- * popups show something on a bare sigil too). */
67
- const EMPTY_QUERY_ROWS = 20
68
-
69
- /**
70
- * Bounded async BFS scan of a workspace; unreadable entries are skipped.
71
- * Both files and directories are indexed (directories insert with a trailing
72
- * slash), mirroring Codex's `MatchType::{File,Directory}` index. Dotfiles and
73
- * the {@link SKIP_DIRS} list are excluded, which is a coarser filter than
74
- * Codex's gitignore-aware walker but stays dependency-free and bounded.
75
- */
76
- export async function scanWorkspaceFiles(root: string, signal?: AbortSignal): Promise<readonly FileCandidate[]> {
77
- const found: FileCandidate[] = []
78
- const pending: Array<{ absolute: string; relative: string; depth: number }> = [{ absolute: root, relative: '', depth: 0 }]
79
- const aborted = (): boolean => signal?.aborted === true
80
- while (pending.length > 0 && found.length < MAX_FILES && !aborted()) {
81
- const current = pending.shift()
82
- if (current === undefined) break
83
- let entries
84
- try {
85
- entries = await readdir(current.absolute, { withFileTypes: true })
86
- } catch {
87
- continue
88
- }
89
- for (const entry of entries) {
90
- if (found.length >= MAX_FILES || aborted()) return found
91
- if (entry.name.startsWith('.')) continue
92
- const relative = current.relative === '' ? entry.name : `${current.relative}/${entry.name}`
93
- if (entry.isDirectory()) {
94
- if (SKIP_DIRS.has(entry.name) || current.depth + 1 > MAX_DEPTH) continue
95
- found.push({ path: relative, kind: 'directory' })
96
- pending.push({ absolute: join(current.absolute, entry.name), relative, depth: current.depth + 1 })
97
- } else if (entry.isFile()) {
98
- found.push({ path: relative, kind: 'file' })
99
- }
100
- }
101
- }
102
- return found.sort((left, right) => left.path < right.path ? -1 : 1)
64
+ /** One path candidate the `ctx.fileReferences` service returns. */
65
+ interface ServiceFileCandidate {
66
+ readonly path: string
67
+ readonly kind: 'file' | 'directory'
103
68
  }
104
69
 
105
- /** True when every query character appears in order in the haystack. */
106
- function isSubsequence(query: string, haystack: string): boolean {
107
- let at = 0
108
- for (const char of haystack) {
109
- if (char === query[at]) at += 1
110
- if (at >= query.length) return true
111
- }
112
- return at >= query.length
70
+ /** The `ctx.fileReferences` service face (dsh-file-reference-local). */
71
+ interface FileReferenceServiceLike {
72
+ list(agent: Agent, query: string, signal: AbortSignal): Promise<readonly ServiceFileCandidate[]>
113
73
  }
114
74
 
115
- /** Rank one file path against the typed query (community-TUI scoring shape). */
116
- function scoreFile(path: string, query: string): number {
117
- const name = path.slice(path.lastIndexOf('/') + 1)
118
- if (query === '') return 0
119
- if (name === query) return 1000
120
- if (name.startsWith(query)) return 900
121
- if (name.includes(query)) return 700
122
- if (path.includes(query)) return 500
123
- if (isSubsequence(query, name)) return 300
124
- return 0
125
- }
75
+ /** Menu cap on file rows; the service owns ranking and default rows. */
76
+ const MAX_FILE_ROWS = 20
126
77
 
127
78
  /** The mention API the input editor and the runner share. */
128
79
  export interface MentionsApi {
129
- /** Scanned workspace files and directories, cached across one session. */
130
- files(): Promise<readonly FileCandidate[]>
131
80
  /** Ranked menu candidates for the typed `@` query. */
132
81
  candidates(query: string, signal?: AbortSignal): Promise<readonly MentionCandidate[]>
133
82
  /** Parse submission text into readable text plus structured references. */
@@ -143,53 +92,65 @@ export interface MentionsApi {
143
92
 
144
93
  /**
145
94
  * Create the mention API for one agent's workspace. A missing
146
- * session-reference service degrades to file mentions only (the scan still
147
- * works); `prepare` then passes text through untouched. An undefined agent
148
- * (a bare launch before any session exists) also degrades to file-only
149
- * mentions, so `@` file completion works before the first message.
95
+ * `fileReferences` service (with an agent present) or `sessionReferenceResolver`
96
+ * degrades that half to empty rows; `prepare` passes text through untouched
97
+ * without references. An undefined agent (a bare launch before any session
98
+ * exists) runs the official WorkspaceFileSearch over the launch cwd — the
99
+ * same class the mounted service uses per agent — so `@` file completion
100
+ * works from the first keystroke; session references wait for the session.
150
101
  *
151
- * `files` is hoisted into the closure so `candidates` never reaches for
152
- * `this` the runner hands `mentions.candidates` to the input editor as a
153
- * detached callback, and a `this`-bound method would throw on every `@` key.
154
- * @param ctx - context carrying the optional `sessionReferenceResolver`.
155
- * @param agent - the session owner; excluded from its own candidates.
156
- * @param cwd - workspace root to scan.
102
+ * `candidates` never reaches for `this` — the runner hands it to the input
103
+ * editor as a detached callback, and a `this`-bound method would throw on
104
+ * every `@` key.
105
+ * @param ctx - context carrying the optional `fileReferences` and
106
+ * `sessionReferenceResolver` services.
107
+ * @param agent - the session owner; excluded from its own session candidates.
108
+ * @param cwd - launch working directory; bounds the pre-session search.
157
109
  */
158
110
  export function createMentions(ctx: Context, agent: Agent | undefined, cwd: string): MentionsApi {
159
111
  const resolver = ctx.get('sessionReferenceResolver')
112
+ const fileReferences = (ctx as unknown as { get(name: string): unknown }).get('fileReferences') as
113
+ | FileReferenceServiceLike
114
+ | undefined
160
115
  const sessionCapable = agent !== undefined && resolver !== undefined
161
- let filesPromise: Promise<readonly FileCandidate[]> | undefined
162
- const files = (): Promise<readonly FileCandidate[]> => {
163
- filesPromise ??= scanWorkspaceFiles(cwd)
164
- return filesPromise
116
+ // Pre-session fallback: one lazily built search over the launch cwd with
117
+ // the official defaults pure in-memory index, no handles to release.
118
+ let preSessionSearch: WorkspaceFileSearch | undefined
119
+ const preSessionFiles = (query: string, signal?: AbortSignal): Promise<readonly ServiceFileCandidate[]> => {
120
+ preSessionSearch ??= new WorkspaceFileSearch(cwd, {
121
+ maxResults: DEFAULT_FILE_SEARCH_MAX_RESULTS,
122
+ maxEntries: DEFAULT_FILE_SEARCH_MAX_ENTRIES,
123
+ excludedDirectories: [...DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES],
124
+ })
125
+ return preSessionSearch.list(query, signal ?? new AbortController().signal)
165
126
  }
166
127
 
167
128
  return {
168
- files,
169
129
  async candidates(query: string, signal?: AbortSignal): Promise<readonly MentionCandidate[]> {
170
130
  const needle = query.trim()
171
- const [scanned, sessions] = await Promise.all([
172
- files(),
131
+ const [files, sessions] = await Promise.all([
132
+ agent !== undefined && fileReferences !== undefined
133
+ ? fileReferences
134
+ .list(agent, needle, signal ?? new AbortController().signal)
135
+ .catch(() => [] as readonly ServiceFileCandidate[])
136
+ : agent === undefined
137
+ ? preSessionFiles(needle, signal).catch(() => [] as readonly ServiceFileCandidate[])
138
+ : Promise.resolve([] as readonly ServiceFileCandidate[]),
173
139
  sessionCapable && needle !== '' && agent !== undefined
174
140
  ? resolver!.listCandidates(agent, needle, 10, signal).catch(() => [] as readonly SessionReferenceCandidate[])
175
141
  : Promise.resolve([] as readonly SessionReferenceCandidate[]),
176
142
  ])
177
- // A bare `@` lists the first path-sorted entries so the menu is live
178
- // before any typing; a non-empty needle ranks by the Codex-shaped score.
179
- const fileRows: MentionCandidate[] = (needle === ''
180
- ? scanned.slice(0, EMPTY_QUERY_ROWS)
181
- : scanned
182
- .filter(candidate => scoreFile(candidate.path, needle) > 0)
183
- .sort((left, right) => scoreFile(right.path, needle) - scoreFile(left.path, needle))
184
- .slice(0, 20))
185
- .map(candidate => ({
186
- label: candidate.path,
187
- description: candidate.kind === 'directory' ? 'Folder' : 'File',
188
- kind: candidate.kind,
189
- }))
190
- // Sessions join only with a typed needle and always AFTER the file
191
- // rows: `@` is a file mention first (Codex's semantics), and session
192
- // references are the secondary vocabulary.
143
+ // The service owns ranking (and the bare-@ default rows); the menu caps
144
+ // file rows and always places sessions after files `@` is a file
145
+ // mention first (Codex's semantics), session references second.
146
+ const fileRows: MentionCandidate[] = files.slice(0, MAX_FILE_ROWS).map(candidate => ({
147
+ label: candidate.path,
148
+ description: candidate.kind === 'directory' ? 'Folder' : 'File',
149
+ kind: candidate.kind,
150
+ ...candidate.kind === 'file'
151
+ ? { path: isAbsolute(candidate.path) ? candidate.path : resolve(cwd, candidate.path) }
152
+ : {},
153
+ }))
193
154
  const sessionRows: MentionCandidate[] = sessions.map(candidate => ({
194
155
  label: formatSessionReferenceMention(candidate),
195
156
  description: `Session · ${candidate.cwd ?? '(no cwd)'}`,
package/src/models.ts CHANGED
@@ -13,10 +13,11 @@ import type { ModelSelection } from '@deepseek-ai/dsh-agent'
13
13
  import {
14
14
  ReasoningEffortId,
15
15
  type LlmCallConfig,
16
- type LlmModelInfo,
17
- type LlmModelReasoningInfo,
18
- type LlmResolvedModelInfo,
19
- } from '@deepseek-ai/dsh-llm'
16
+ type LlmModelInfo,
17
+ type LlmModelReasoningInfo,
18
+ type LlmResolvedModelInfo,
19
+ type ModelModality,
20
+ } from '@deepseek-ai/dsh-llm'
20
21
 
21
22
  /** Display metadata for one adapter-owned reasoning effort (mirrors `LlmReasoningEffortInfo`). */
22
23
  export interface ModelReasoningEffort {
@@ -44,9 +45,11 @@ export interface ModelRow {
44
45
  providerName: string
45
46
  /** Provider-owned model id. */
46
47
  model: string
47
- /** Human-readable model name. */
48
- modelName: string
49
- /** Adapter-owned selectable reasoning levels when the model exposes any. */
48
+ /** Human-readable model name. */
49
+ modelName: string
50
+ /** Request modalities advertised for this exact route; absent means unknown. */
51
+ inputModalities?: readonly ModelModality[]
52
+ /** Adapter-owned selectable reasoning levels when the model exposes any. */
50
53
  reasoning?: ModelReasoning
51
54
  }
52
55
 
@@ -194,16 +197,19 @@ export async function loadModelDirectory(ctx: Context): Promise<ModelDirectory>
194
197
  const rows = await Promise.all(models.map(async (model): Promise<ModelRow> => {
195
198
  const row: ModelRow = {
196
199
  provider: provider.id,
197
- providerName: provider.name,
198
- model: model.id,
199
- modelName: model.name,
200
- }
200
+ providerName: provider.name,
201
+ model: model.id,
202
+ modelName: model.name,
203
+ ...model.inputModalities === undefined ? {} : { inputModalities: [...model.inputModalities] },
204
+ }
201
205
  if (llmResolve.resolveModelInfo === undefined) return row
202
206
  try {
203
207
  const resolved = await llmResolve.resolveModelInfo(provider.id, model.id)
204
- return resolved.reasoning === undefined
205
- ? row
206
- : { ...row, reasoning: mapReasoning(resolved.reasoning) }
208
+ return {
209
+ ...row,
210
+ ...resolved.inputModalities === undefined ? {} : { inputModalities: [...resolved.inputModalities] },
211
+ ...resolved.reasoning === undefined ? {} : { reasoning: mapReasoning(resolved.reasoning) },
212
+ }
207
213
  } catch {
208
214
  reasoningFailures.push(`${provider.id}/${model.id}`)
209
215
  return row
@@ -1,7 +1,8 @@
1
1
  /** Permission-preset policy for pending and active TUI sessions. */
2
2
 
3
3
  import type { Context } from '@deepseek-ai/cordis'
4
- import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
4
+ import type { PermissionPresetService } from '@deepseek-ai/dsh-permission-presets'
5
+ import type { Session } from '@deepseek-ai/dsh-session'
5
6
 
6
7
  /** One selectable permission preset row for the /permission panel. */
7
8
  export interface PermissionRow {
@@ -9,20 +10,12 @@ export interface PermissionRow {
9
10
  readonly description?: string
10
11
  }
11
12
 
12
- /** Structural boundary over Harness permission presets; values stay service-owned. */
13
- export interface PermissionPresetsService {
14
- readonly names: readonly string[]
15
- readonly defaultPreset: string
16
- resolve(name: string): unknown
17
- current(events: readonly SessionEvent[]): string
18
- set(session: Session, preset: string): void
19
- /** Client presentation metadata for one preset; may reject unknown names. */
20
- optionOf?(name: string): { name: string; description?: string } | undefined
21
- }
13
+ /** Public compatibility alias for the official upstream permission service. */
14
+ export type PermissionPresetsService = PermissionPresetService
22
15
 
23
16
  /** Read the optional Harness service without importing its runtime package. */
24
17
  export function permissionPresetsFrom(ctx: Context): PermissionPresetsService | undefined {
25
- return (ctx as unknown as { get(name: string): unknown }).get('permissionPresets') as PermissionPresetsService | undefined
18
+ return ctx.get('permissionPresets')
26
19
  }
27
20
 
28
21
  /** Effective label for either an active session or the not-yet-created first one. */
@@ -75,7 +68,6 @@ export function applyPendingPermission(
75
68
  */
76
69
  export function listPermissionRows(service: PermissionPresetsService): readonly PermissionRow[] {
77
70
  return service.names.map((id) => {
78
- if (service.optionOf === undefined) return { id }
79
71
  try {
80
72
  return { id, description: service.optionOf(id)?.description }
81
73
  } catch {
package/src/presets.ts CHANGED
@@ -1,32 +1,19 @@
1
1
  /** Agent-preset policy kept independent from the Ink surface. */
2
2
 
3
3
  import type { Context } from '@deepseek-ai/cordis'
4
- import type { Agent, AgentHandle } from '@deepseek-ai/dsh-agent'
4
+ import type { Agent } from '@deepseek-ai/dsh-agent'
5
+ import type { AgentPreset, AgentPresets } from '@deepseek-ai/dsh-agent-presets'
5
6
  import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
6
7
 
7
8
  /** One discoverable agent composition. */
8
- export interface PresetRow {
9
- readonly id: string
10
- readonly trust: 'system' | 'user'
11
- readonly name?: string
12
- readonly description?: string
13
- readonly order?: number
14
- readonly broken?: string
15
- }
9
+ export type PresetRow = AgentPreset
16
10
 
17
- /** Structural boundary for the optional upstream AgentPresets service. */
18
- export interface AgentPresetsService {
19
- readonly defaultId: string
20
- list(): Promise<PresetRow[]>
21
- resolve(id?: string): Promise<PresetRow>
22
- mount(agentCtx: Context, id?: string): Promise<PresetRow>
23
- recompose(agentCtx: Context, id: string): Promise<PresetRow>
24
- composedPreset(agentCtx: Context): string | undefined
25
- }
11
+ /** Public compatibility alias for the official upstream service type. */
12
+ export type AgentPresetsService = AgentPresets
26
13
 
27
14
  /** Read an optional Cordis service without requiring its package at build time. */
28
15
  export function agentPresetsFrom(ctx: Context): AgentPresetsService | undefined {
29
- return (ctx as unknown as { get(name: string): unknown }).get('agentPresets') as AgentPresetsService | undefined
16
+ return ctx.get('agentPresets')
30
17
  }
31
18
 
32
19
  /** A preset may change only before the first durable turn begins. */
@@ -71,6 +58,3 @@ export async function switchPreset(
71
58
  writable.append('agent-preset/selected', { agentPreset: preset.id })
72
59
  return preset
73
60
  }
74
-
75
- /** Minimal handle shape used by lifecycle tests without exposing Agent internals. */
76
- export type OwnedAgent = Pick<AgentHandle, 'agent' | 'dispose'>