dsh-code 1.0.4 → 1.0.6

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 (41) hide show
  1. package/README.en.md +287 -286
  2. package/README.md +16 -13
  3. package/bin/deepseek.mjs +336 -11
  4. package/cordis.patch.yml +26 -18
  5. package/lib/index.mjs +1310 -439
  6. package/lib/types/app.d.ts +25 -6
  7. package/lib/types/attachments.d.ts +36 -4
  8. package/lib/types/git-workflow.d.ts +7 -2
  9. package/lib/types/history.d.ts +18 -11
  10. package/lib/types/index.d.ts +11 -2
  11. package/lib/types/presets.d.ts +4 -1
  12. package/lib/types/provider-settings.d.ts +6 -11
  13. package/lib/types/questions.d.ts +16 -12
  14. package/lib/types/render/animations.d.ts +74 -7
  15. package/lib/types/render/export.d.ts +0 -6
  16. package/lib/types/render/fuzzy.d.ts +21 -0
  17. package/lib/types/render/projection.d.ts +47 -5
  18. package/lib/types/session-directory.d.ts +48 -13
  19. package/lib/types/settings-file.d.ts +8 -0
  20. package/lib/types/store.d.ts +3 -0
  21. package/package.json +168 -159
  22. package/src/app.ts +480 -199
  23. package/src/attachments.ts +110 -11
  24. package/src/commands.ts +35 -5
  25. package/src/git-workflow.ts +29 -10
  26. package/src/history.ts +22 -13
  27. package/src/index.ts +1868 -1752
  28. package/src/internals.ts +61 -40
  29. package/src/permissions.ts +1 -1
  30. package/src/presets.ts +19 -6
  31. package/src/provider-settings.ts +12 -12
  32. package/src/questions.ts +57 -74
  33. package/src/render/animations.ts +606 -403
  34. package/src/render/export.ts +20 -10
  35. package/src/render/fuzzy.ts +83 -0
  36. package/src/render/projection.ts +1833 -1620
  37. package/src/session-directory.ts +94 -16
  38. package/src/settings-file.ts +38 -6
  39. package/src/skills.ts +23 -9
  40. package/src/store.ts +39 -1
  41. package/src/subagents.ts +26 -3
package/src/internals.ts CHANGED
@@ -44,46 +44,67 @@ export const internals: {
44
44
  // the keyboard protocol on terminals that can safely own those key events.
45
45
  const keyboardEnhanced = shouldEnableKeyboardEnhancement()
46
46
  const focusReporting = isVsCodeTerminalEnv()
47
- process.stdout.write(
48
- (keyboardEnhanced ? KEYBOARD_ENHANCE_ENABLE : '')
49
- + BRACKETED_PASTE_ENABLE
50
- + (focusReporting ? TERMINAL_FOCUS_REPORT_ENABLE : ''),
51
- )
52
- // App owns Ctrl+C's deliberate three-state contract (interrupt, clear
53
- // draft, quit). Ink's default `exitOnCtrlC: true` would intercept the
54
- // normalized control byte first, unmount only its renderer, and leave the
55
- // Harness runner plus the pushed keyboard protocol alive.
56
- // stdin travels through the keypress splitter: Ink parses one chunk as
57
- // one keypress, so a coalesced space-then-enter would drop both keys.
58
- const tuiStdin = createSplitStdin(process.stdin)
59
- // Ink only touches isTTY/setRawMode/ref/read on stdin; the object-mode
60
- // proxy satisfies that contract without the full ReadStream surface.
61
- const instance = render(element, {
62
- exitOnCtrlC: false,
63
- stdin: tuiStdin.stdin as unknown as NodeJS.ReadStream,
64
- stdout: process.stdout,
65
- })
66
- return {
67
- rerender(element: ReactElement): void {
68
- instance.rerender(element)
69
- },
70
- unmount(): void {
71
- // The cleanup below must run even when Ink's unmount throws (a
72
- // render-teardown failure): a stdin tap or pushed terminal-protocol
73
- // stack outliving the app wedges the terminal for whatever runs
74
- // next, and a stray exception here must not skip the exit sequence.
75
- try {
76
- instance.unmount()
77
- } finally {
78
- tuiStdin.dispose()
79
- // Pop only a stack this mount pushed, then disable bracketed paste.
80
- process.stdout.write(
81
- (keyboardEnhanced ? KEYBOARD_ENHANCE_DISABLE : '')
82
- + BRACKETED_PASTE_DISABLE
83
- + (focusReporting ? TERMINAL_FOCUS_REPORT_DISABLE : ''),
84
- )
85
- }
86
- },
47
+ // Enter raw mode BEFORE pushing any protocol: xterm.js answers `?1004h`
48
+ // with an immediate focus report (ESC[I), and while the tty still carries
49
+ // the shell's cooked+ECHO settings that report is echoed to the screen as
50
+ // a literal `^[[I`. Ink only takes raw mode after its first commit, so
51
+ // this mount owns the window; the call is idempotent with Ink's later one.
52
+ if (process.stdin.isTTY === true) process.stdin.setRawMode?.(true)
53
+ try {
54
+ process.stdout.write(
55
+ (keyboardEnhanced ? KEYBOARD_ENHANCE_ENABLE : '')
56
+ + BRACKETED_PASTE_ENABLE
57
+ + (focusReporting ? TERMINAL_FOCUS_REPORT_ENABLE : ''),
58
+ )
59
+ // App owns Ctrl+C's deliberate three-state contract (interrupt, clear
60
+ // draft, quit). Ink's default `exitOnCtrlC: true` would intercept the
61
+ // normalized control byte first, unmount only its renderer, and leave the
62
+ // Harness runner plus the pushed keyboard protocol alive.
63
+ // stdin travels through the keypress splitter: Ink parses one chunk as
64
+ // one keypress, so a coalesced space-then-enter would drop both keys.
65
+ const tuiStdin = createSplitStdin(process.stdin)
66
+ // Ink only touches isTTY/setRawMode/ref/read on stdin; the object-mode
67
+ // proxy satisfies that contract without the full ReadStream surface.
68
+ const instance = render(element, {
69
+ exitOnCtrlC: false,
70
+ stdin: tuiStdin.stdin as unknown as NodeJS.ReadStream,
71
+ stdout: process.stdout,
72
+ })
73
+ return {
74
+ rerender(element: ReactElement): void {
75
+ instance.rerender(element)
76
+ },
77
+ unmount(): void {
78
+ // The cleanup below must run even when Ink's unmount throws (a
79
+ // render-teardown failure): a stdin tap or pushed terminal-protocol
80
+ // stack outliving the app wedges the terminal for whatever runs
81
+ // next, and a stray exception here must not skip the exit sequence.
82
+ // Pop the stack while raw mode still hides echo — xterm.js keeps
83
+ // reporting focus changes until `?1004l` lands, and one arriving
84
+ // after Ink restores the cooked tty would print as `^[[I`.
85
+ try {
86
+ process.stdout.write(
87
+ (keyboardEnhanced ? KEYBOARD_ENHANCE_DISABLE : '')
88
+ + BRACKETED_PASTE_DISABLE
89
+ + (focusReporting ? TERMINAL_FOCUS_REPORT_DISABLE : ''),
90
+ )
91
+ } finally {
92
+ try {
93
+ instance.unmount()
94
+ } finally {
95
+ tuiStdin.dispose()
96
+ // Belt and braces: give the tty back its cooked mode even when
97
+ // Ink never took raw mode over (idempotent at the termios level).
98
+ if (process.stdin.isTTY === true) process.stdin.setRawMode?.(false)
99
+ }
100
+ }
101
+ },
102
+ }
103
+ } catch (error) {
104
+ // The synchronous mount path failed before Ink could own the terminal:
105
+ // undo the raw mode entered above so the shell keeps its echo.
106
+ if (process.stdin.isTTY === true) process.stdin.setRawMode?.(false)
107
+ throw error
87
108
  }
88
109
  },
89
110
  stderr: process.stderr,
@@ -24,7 +24,7 @@ export function effectivePermission(
24
24
  session: Session | undefined,
25
25
  pending: string | undefined,
26
26
  ): string {
27
- return session === undefined ? pending ?? service.defaultPreset : service.current(session.events)
27
+ return session === undefined ? pending ?? service.defaultPreset : service.current(session)
28
28
  }
29
29
 
30
30
  /** Validate a preset and write it only when a durable session already exists. */
package/src/presets.ts CHANGED
@@ -21,15 +21,27 @@ export function isBlankSession(events: readonly SessionEvent[]): boolean {
21
21
  return !events.some(event => event.type === 'turn/start')
22
22
  }
23
23
 
24
+ /** Upstream renamed the shipped `code` preset to `ptc` in 0.1.2-rc.1; sessions
25
+ * and CLI choices recorded before the rename keep resolving through this map. */
26
+ const LEGACY_PRESET_IDS: Readonly<Record<string, string>> = { code: 'ptc' }
27
+
28
+ /** Translate a preset id recorded before an upstream rename to its current id. */
29
+ export function normalizePresetId(id: string): string
30
+ export function normalizePresetId(id: string | undefined): string | undefined
31
+ export function normalizePresetId(id: string | undefined): string | undefined {
32
+ return id === undefined ? undefined : LEGACY_PRESET_IDS[id] ?? id
33
+ }
34
+
24
35
  /** Latest logged selection wins; legacy sessions deliberately fall back to standard. */
25
- export function resolvePreset(session: Pick<Session, 'header' | 'events'>): string {
26
- for (let index = session.events.length - 1; index >= 0; index -= 1) {
27
- const event = session.events[index] as unknown as { type: string; data?: { agentPreset?: string } }
36
+ export function resolvePreset(session: Pick<Session, 'header' | 'snapshotEvents'>): string {
37
+ const events = session.snapshotEvents()
38
+ for (let index = events.length - 1; index >= 0; index -= 1) {
39
+ const event = events[index] as unknown as { type: string; data?: { agentPreset?: string } }
28
40
  if (event.type === 'agent-preset/selected' && event.data?.agentPreset !== undefined) {
29
- return event.data.agentPreset
41
+ return normalizePresetId(event.data.agentPreset) as string
30
42
  }
31
43
  }
32
- return session.header.agentPreset ?? 'standard'
44
+ return normalizePresetId(session.header.agentPreset) ?? 'standard'
33
45
  }
34
46
 
35
47
  /** Resolve a pre-session choice, or recompose an active blank Agent. */
@@ -38,6 +50,7 @@ export async function selectPreset(
38
50
  agent: Agent | undefined,
39
51
  presetId: string,
40
52
  ): Promise<PresetRow> {
53
+ presetId = normalizePresetId(presetId)
41
54
  if (agent !== undefined) return switchPreset(service, agent, presetId)
42
55
  const preset = await service.resolve(presetId)
43
56
  if (preset.broken !== undefined) throw new Error(preset.broken)
@@ -50,7 +63,7 @@ export async function switchPreset(
50
63
  agent: Agent,
51
64
  presetId: string,
52
65
  ): Promise<PresetRow> {
53
- if (!isBlankSession(agent.session.events)) {
66
+ if (!isBlankSession(agent.session.snapshotEvents())) {
54
67
  throw new Error('mode is locked after the first turn; use /new <mode>')
55
68
  }
56
69
  const preset = await service.recompose(agent.ctx, presetId)
@@ -33,6 +33,8 @@ interface LlmFace {
33
33
  readonly settingsNs: string
34
34
  readonly settingsPath: readonly string[]
35
35
  readonly declared?: boolean
36
+ /** Configuration diagnostic for repair; unaffected models may remain serviceable. */
37
+ readonly error?: string
36
38
  }[]
37
39
  /**
38
40
  * Registered endpoint model discovery; absent on an older service. The
@@ -256,18 +258,6 @@ export interface DiscoveredModelView {
256
258
  readonly maxTokens?: number
257
259
  }
258
260
 
259
- /** One model an endpoint reported about itself (mirrors LlmDiscoveredModel). */
260
- export interface DiscoveredModelView {
261
- /** Model id the endpoint accepts. */
262
- readonly id: string
263
- /** Human-readable name when the endpoint supplies one. */
264
- readonly name?: string
265
- /** Context window when disclosed; adoption still owes it if absent. */
266
- readonly contextWindow?: number
267
- /** Output cap when disclosed. */
268
- readonly maxTokens?: number
269
- }
270
-
271
261
  /**
272
262
  * The seven canonical reasoning levels a reasoningEfforts key may name -
273
263
  * pi-ai's THINKING_LEVELS. A pi-ai upgrade that adds or removes one fails
@@ -362,6 +352,12 @@ export interface ProviderTargetView {
362
352
  readonly configuration: ProviderConfiguration
363
353
  /** The owning adapter reports this route as hand-declared (absent when it draws no distinction). */
364
354
  readonly declared?: boolean
355
+ /**
356
+ * Configuration diagnostic the adapter reported for this route (catalog or
357
+ * profile damage): the row stays listed and repairable instead of the whole
358
+ * provider vanishing; absent when the route reads clean.
359
+ */
360
+ readonly diagnostic?: string
365
361
  }
366
362
 
367
363
  /** The resolved provider/settings/credential join. */
@@ -431,6 +427,7 @@ export async function loadProviderSettings(ctx: Context): Promise<ProviderSettin
431
427
  settingsNs: string
432
428
  settingsPath: readonly string[]
433
429
  declared?: boolean
430
+ error?: string
434
431
  }> = []
435
432
  if (llm.listConfigurableProviders !== undefined) {
436
433
  try {
@@ -462,6 +459,7 @@ export async function loadProviderSettings(ctx: Context): Promise<ProviderSettin
462
459
  settingsNs: string
463
460
  settingsPath: readonly string[]
464
461
  declared?: boolean
462
+ error?: string
465
463
  }> = [
466
464
  ...directoryEntries.map(entry => ({
467
465
  provider: entry.provider,
@@ -470,6 +468,7 @@ export async function loadProviderSettings(ctx: Context): Promise<ProviderSettin
470
468
  settingsNs: entry.settingsNs,
471
469
  settingsPath: entry.settingsPath,
472
470
  ...entry.declared === undefined ? {} : { declared: entry.declared },
471
+ ...entry.error === undefined ? {} : { error: singleLine(entry.error) },
473
472
  })),
474
473
  ...registered
475
474
  .filter(provider => !declared.has(provider.id))
@@ -508,6 +507,7 @@ export async function loadProviderSettings(ctx: Context): Promise<ProviderSettin
508
507
  ...credentialRef === undefined ? {} : { credentialRef },
509
508
  suggestedRef: deriveCredentialRef(base.provider),
510
509
  ...base.declared === undefined ? {} : { declared: base.declared },
510
+ ...base.error === undefined ? {} : { diagnostic: base.error },
511
511
  }
512
512
  })
513
513
  const refs = [...new Set(rows.flatMap(row => row.credentialRef === undefined ? [] : [row.credentialRef]))]
package/src/questions.ts CHANGED
@@ -1,12 +1,12 @@
1
1
  /**
2
- * The terminal ask_user_question provider: registers the single UI provider
3
- * on `ctx.userQuestions` and drives it with a FIFO queue — one question
4
- * request on screen at a time, everything else waiting — then resolves the
5
- * collected answers back into the tool's promise. The community TUI proved
6
- * this exact pipeline shape; here the dialog is an Ink bar instead of a
7
- * pi-tui inline modal.
2
+ * The terminal ask_user_question answerer: one `user-questions/request`
3
+ * waterfall listener that drives a FIFO queue — one question request on
4
+ * screen at a time, everything else waiting — then resolves the collected
5
+ * answers back into the waterfall. Mirrors the approval answerer's claim/
6
+ * defer split: only agents this TUI owns are answered, every other request
7
+ * falls through to the next answerer.
8
8
  *
9
- * Plan reviews (`exit_plan_mode`) arrive through the same service with an
9
+ * Plan reviews (`exit_plan_mode`) arrive through the same waterfall with an
10
10
  * `intent: { kind: 'plan-review' }` — the renderer highlights the approve
11
11
  * option; the answer encoding is identical either way.
12
12
  *
@@ -14,6 +14,7 @@
14
14
  */
15
15
 
16
16
  import type { Context } from '@deepseek-ai/cordis'
17
+ import type { Agent } from '@deepseek-ai/dsh-agent'
17
18
  import {
18
19
  UserQuestionError,
19
20
  type AskUserQuestionAnswer,
@@ -56,13 +57,15 @@ const ABORT_ERROR = new UserQuestionError(
56
57
  )
57
58
 
58
59
  /**
59
- * Mount the single `ctx.userQuestions` UI provider over a FIFO queue.
60
- * @param ctx - context carrying the `userQuestions` service (dsh-base).
61
- * @returns the store the renderer subscribes to; a context without the
62
- * service yields a permanently empty store.
60
+ * Mount the `user-questions/request` answerer over a FIFO queue.
61
+ * @param ctx - plugin context whose event bus carries the waterfall.
62
+ * @param owns - agents this terminal answers for; every other request is
63
+ * deferred back into the waterfall (`next()`), so sibling answerers stay
64
+ * usable. Agent-less asks are claimed: this TUI is the only human surface
65
+ * in the process.
66
+ * @returns the store the renderer subscribes to.
63
67
  */
64
- export function mountQuestionProvider(ctx: Context): QuestionStore {
65
- const service = ctx.get('userQuestions')
68
+ export function mountQuestionProvider(ctx: Context, owns: (agent: Agent) => boolean): QuestionStore {
66
69
  let snapshot: QuestionSnapshot = { pending: undefined }
67
70
  let active: PendingQuestion | undefined
68
71
  const queue: PendingQuestion[] = []
@@ -79,69 +82,49 @@ export function mountQuestionProvider(ctx: Context): QuestionStore {
79
82
  set({ pending: next })
80
83
  }
81
84
 
82
- if (service !== undefined) {
83
- // Capability guard against contract drift: the pinned release exposes
84
- // registerProvider, but an upstream alignment may replace it with the
85
- // 'user-questions/request' waterfall. Degrade to the permanently-empty
86
- // store (the no-service path) instead of failing startup with a
87
- // TypeError on a missing method.
88
- if (typeof (service as unknown as { registerProvider?: unknown }).registerProvider !== 'function') {
89
- return {
90
- subscribe(listener: () => void): () => void {
91
- listeners.add(listener)
92
- return () => {
93
- listeners.delete(listener)
94
- }
95
- },
96
- getSnapshot(): QuestionSnapshot {
97
- return snapshot
98
- },
99
- submit(): void {},
100
- cancel(): void {},
85
+ ctx.on('user-questions/request', (
86
+ request: AskUserQuestionRequest,
87
+ next: () => Promise<AskUserQuestionAnswer>,
88
+ ): Promise<AskUserQuestionAnswer> => {
89
+ if (request.agent !== undefined && !owns(request.agent)) return next()
90
+ return new Promise((resolve, reject) => {
91
+ // Abort settles through the same channel as an Esc cancel: the
92
+ // owning tool/step died, so the answer must not linger.
93
+ const onAbort = (): void => {
94
+ if (active === pending) {
95
+ active = undefined
96
+ set({ pending: undefined })
97
+ advance()
98
+ } else {
99
+ const at = queue.indexOf(pending)
100
+ if (at >= 0) queue.splice(at, 1)
101
+ }
102
+ reject(ABORT_ERROR)
103
+ }
104
+ // Detach on every settle so an answered/cancelled question never
105
+ // retains a listener on the owning tool call's signal.
106
+ const detachAbort = (): void => {
107
+ if (request.signal !== undefined) request.signal.removeEventListener('abort', onAbort)
108
+ }
109
+ const pending: PendingQuestion = {
110
+ request,
111
+ resolve,
112
+ reject,
113
+ detachAbort,
114
+ }
115
+ if (request.signal?.aborted === true) {
116
+ reject(ABORT_ERROR)
117
+ return
118
+ }
119
+ request.signal?.addEventListener('abort', onAbort, { once: true })
120
+ if (active === undefined) {
121
+ active = pending
122
+ set({ pending })
123
+ } else {
124
+ queue.push(pending)
101
125
  }
102
- }
103
- service.registerProvider({
104
- ask(request: AskUserQuestionRequest): Promise<AskUserQuestionAnswer> {
105
- return new Promise((resolve, reject) => {
106
- // Abort settles through the same channel as an Esc cancel: the
107
- // owning tool/step died, so the answer must not linger.
108
- const onAbort = (): void => {
109
- if (active === pending) {
110
- active = undefined
111
- set({ pending: undefined })
112
- advance()
113
- } else {
114
- const at = queue.indexOf(pending)
115
- if (at >= 0) queue.splice(at, 1)
116
- }
117
- reject(ABORT_ERROR)
118
- }
119
- // Detach on every settle so an answered/cancelled question never
120
- // retains a listener on the owning tool call's signal.
121
- const detachAbort = (): void => {
122
- if (request.signal !== undefined) request.signal.removeEventListener('abort', onAbort)
123
- }
124
- const pending: PendingQuestion = {
125
- request,
126
- resolve,
127
- reject,
128
- detachAbort,
129
- }
130
- if (request.signal?.aborted === true) {
131
- reject(ABORT_ERROR)
132
- return
133
- }
134
- request.signal?.addEventListener('abort', onAbort, { once: true })
135
- if (active === undefined) {
136
- active = pending
137
- set({ pending })
138
- } else {
139
- queue.push(pending)
140
- }
141
- })
142
- },
143
126
  })
144
- }
127
+ })
145
128
 
146
129
  return {
147
130
  subscribe(listener: () => void): () => void {