dsh-code 1.0.3 → 1.0.5

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 (45) hide show
  1. package/README.md +293 -285
  2. package/bin/deepseek.mjs +245 -12
  3. package/cordis.patch.yml +12 -14
  4. package/lib/index.mjs +1939 -903
  5. package/lib/types/app.d.ts +11 -2
  6. package/lib/types/commands.d.ts +13 -0
  7. package/lib/types/git-workflow.d.ts +7 -2
  8. package/lib/types/history.d.ts +18 -11
  9. package/lib/types/index.d.ts +28 -0
  10. package/lib/types/input-split.d.ts +54 -0
  11. package/lib/types/kernel-panels.d.ts +3 -1
  12. package/lib/types/keyboard.d.ts +8 -0
  13. package/lib/types/presets.d.ts +4 -1
  14. package/lib/types/provider-settings.d.ts +77 -0
  15. package/lib/types/questions.d.ts +16 -12
  16. package/lib/types/render/projection.d.ts +9 -2
  17. package/lib/types/render/status.d.ts +22 -15
  18. package/lib/types/settings-file.d.ts +8 -0
  19. package/lib/types/skills.d.ts +1 -1
  20. package/package.json +49 -46
  21. package/src/app.ts +5459 -4900
  22. package/src/approval.ts +8 -3
  23. package/src/authorization-panel.ts +2 -4
  24. package/src/commands.ts +27 -3
  25. package/src/git-workflow.ts +29 -10
  26. package/src/history.ts +22 -13
  27. package/src/index.ts +203 -61
  28. package/src/input-split.ts +191 -0
  29. package/src/internals.ts +26 -8
  30. package/src/kernel-panels.ts +26 -10
  31. package/src/keyboard.ts +123 -88
  32. package/src/mentions.ts +42 -9
  33. package/src/permissions.ts +1 -1
  34. package/src/presets.ts +19 -6
  35. package/src/provider-settings.ts +204 -0
  36. package/src/questions.ts +58 -55
  37. package/src/render/export.ts +7 -7
  38. package/src/render/lines.ts +24 -12
  39. package/src/render/markdown.ts +15 -13
  40. package/src/render/projection.ts +101 -13
  41. package/src/render/status.ts +76 -71
  42. package/src/render/text.ts +9 -3
  43. package/src/settings-file.ts +38 -6
  44. package/src/skills.ts +19 -6
  45. package/src/theme-panel.ts +79 -72
package/src/approval.ts CHANGED
@@ -96,6 +96,13 @@ export function mountApprovalAnswerer(
96
96
 
97
97
  let resolved = false
98
98
  let settle!: (outcome: ApprovalOutcome) => void
99
+ // Established BEFORE the abort listener mounts: a synchronous throw
100
+ // between the listener registration and a later construction site would
101
+ // otherwise leave a subsequent abort invoking an unassigned settle from
102
+ // inside the AbortSignal listener (an uncaughtException).
103
+ const settled = new Promise<ApprovalOutcome>((resolve) => {
104
+ settle = resolve
105
+ })
99
106
  const signal = request.signal
100
107
  const onAbort = (): void => withdraw()
101
108
  // Detach on every settle so an answered ask never retains a listener on
@@ -136,9 +143,7 @@ export function mountApprovalAnswerer(
136
143
  queue.push(slot)
137
144
  publish()
138
145
 
139
- return new Promise<ApprovalOutcome>((resolve) => {
140
- settle = resolve
141
- }).then((outcome) => {
146
+ return settled.then((outcome) => {
142
147
  if (outcome !== 'cancelled') {
143
148
  removeSlot(slot)
144
149
  publish()
@@ -196,8 +196,7 @@ export function ProviderAuthorizationPanel(props: ProviderAuthorizationPanelProp
196
196
  if (input !== '' && !key.ctrl && !key.meta) setDraft(current => current + input)
197
197
  })
198
198
 
199
- if (viewport.maxHeight === 0) return createElement(Box, { display: 'none' })
200
- if (viewport.compact) {
199
+ if (viewport.maxHeight === 0 || viewport.compact) {
201
200
  return createElement(Text, { wrap: 'truncate-end' }, truncateColumns('provider login · esc cancel', viewport.contentColumns))
202
201
  }
203
202
 
@@ -271,8 +270,7 @@ export function ProviderAuthorizationLogoutPanel({ row, confirm, done, back }: {
271
270
  setError(reason instanceof Error ? reason.message : String(reason))
272
271
  })
273
272
  })
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))
273
+ if (viewport.maxHeight === 0 || viewport.compact) return createElement(Text, { wrap: 'truncate-end' }, truncateColumns('y logout · n/esc back', viewport.contentColumns))
276
274
  return createElement(
277
275
  Box,
278
276
  { flexDirection: 'column', width: viewport.outerColumns, paddingX: 1, borderStyle: 'round', borderColor: inkColor(getPalette().warn) },
package/src/commands.ts CHANGED
@@ -38,16 +38,22 @@ export function watchCommands(ctx: Context): CommandsView {
38
38
  let agent: Agent | undefined
39
39
  let descriptors: readonly CommandDescriptor[] = []
40
40
  let error: string | undefined
41
+ // The agent whose scoped view the current descriptors were read for: a
42
+ // failure before this agent ever loaded clears the list instead of keeping
43
+ // another session's commands completable here.
44
+ let loadedFor: Agent | undefined
41
45
  const listeners = new Set<() => void>()
42
46
  const refresh = (): void => {
43
47
  if (commands === undefined || agent === undefined) return
44
48
  try {
45
49
  descriptors = commands.list(agent)
50
+ loadedFor = agent
46
51
  error = undefined
47
52
  } catch (cause: unknown) {
48
- // Keep the last good catalog, but change its identity so subscribers
49
- // can render the recoverable failure in /help.
50
- descriptors = [...descriptors]
53
+ // Keep the last good catalog for the SAME agent, but change its identity
54
+ // so subscribers can render the recoverable failure in /help; an agent
55
+ // that never loaded starts from empty.
56
+ descriptors = loadedFor === agent ? [...descriptors] : []
51
57
  error = cause instanceof Error ? cause.message : String(cause)
52
58
  }
53
59
  for (const listener of listeners) listener()
@@ -83,3 +89,21 @@ export function watchCommands(ctx: Context): CommandsView {
83
89
  export function isSlashLine(line: string): boolean {
84
90
  return /^\/[a-z][a-z0-9_-]*(?=$|[\t ])/u.test(line)
85
91
  }
92
+
93
+ /**
94
+ * The submission payload for one composer line. Trim is a blank check, not a
95
+ * rewrite: an ordinary prompt keeps its exact leading indentation, inner
96
+ * layout, and trailing spaces (pasted code must reach the model verbatim).
97
+ * Only trailing line terminators are stripped — a draft's final newline is a
98
+ * paste/Enter artifact (an open bracketed paste turns Enter into an inserted
99
+ * newline), never deliberate content. A syntactic slash line still normalizes
100
+ * fully so command routing stays stable (completion inserts a trailing space
101
+ * after `/name`).
102
+ * @param line - the complete draft text.
103
+ * @returns the text to submit verbatim.
104
+ */
105
+ export function submissionPayload(line: string): string {
106
+ const withoutTrailingNewlines = line.replace(/[\r\n]+$/u, '')
107
+ const trimmed = withoutTrailingNewlines.trim()
108
+ return isSlashLine(trimmed) ? trimmed : withoutTrailingNewlines
109
+ }
@@ -1,4 +1,9 @@
1
- /** Read-only Git inspection used by /diff and /review. */
1
+ /**
2
+ * Read-only Git inspection used by /diff and /review. Every diff
3
+ * invocation carries --no-ext-diff and --no-textconv, so configured
4
+ * external diff drivers and text converters can never execute as a
5
+ * side effect of reading a diff.
6
+ */
2
7
 
3
8
  import { execFile } from 'node:child_process'
4
9
 
@@ -36,12 +41,12 @@ export function parseGitDiffFiles(text: string): readonly GitDiffFile[] {
36
41
  /** Parse the intentionally small, option-safe /diff argument vocabulary. */
37
42
  export function parseGitDiffSpec(argument: string): GitDiffSpec {
38
43
  const value = argument.trim()
39
- if (value === '') return { label: 'working tree vs HEAD', args: ['diff', '--no-ext-diff', '--unified=3', 'HEAD', '--'] }
44
+ if (value === '') return { label: 'working tree vs HEAD', args: ['diff', '--no-ext-diff', '--no-textconv', '--unified=3', 'HEAD', '--'] }
40
45
  if (value === '--staged' || value === '--cached') {
41
- return { label: 'staged changes', args: ['diff', '--no-ext-diff', '--unified=3', '--cached', '--'] }
46
+ return { label: 'staged changes', args: ['diff', '--no-ext-diff', '--no-textconv', '--unified=3', '--cached', '--'] }
42
47
  }
43
48
  if (value.startsWith('-') || /\s/u.test(value)) throw new Error('usage: /diff [--staged|git-ref]')
44
- return { label: `changes since ${value}`, args: ['diff', '--no-ext-diff', '--unified=3', value, '--'] }
49
+ return { label: `changes since ${value}`, args: ['diff', '--no-ext-diff', '--no-textconv', '--unified=3', value, '--'] }
45
50
  }
46
51
 
47
52
  function executeGit(cwd: string, args: readonly string[], signal?: AbortSignal): Promise<string> {
@@ -56,8 +61,18 @@ function executeGit(cwd: string, args: readonly string[], signal?: AbortSignal):
56
61
  })
57
62
  }
58
63
 
64
+ /** Arguments for the unstaged-only fallback below. */
65
+ const UNSTAGED_DIFF_ARGS = ['diff', '--no-ext-diff', '--no-textconv', '--unified=3', '--'] as const
66
+
67
+ /** Whether the repository has at least one commit (a HEAD revision). */
68
+ function hasHeadRevision(cwd: string, signal?: AbortSignal): Promise<boolean> {
69
+ return executeGit(cwd, ['rev-parse', '--verify', '--quiet', 'HEAD'], signal)
70
+ .then(() => true)
71
+ .catch(() => false)
72
+ }
73
+
59
74
  /**
60
- * Load one complete textual diff without invoking external diff drivers.
75
+ * Load one complete textual diff without invoking external programs.
61
76
  * @param signal - aborted by the caller on session switches/quit, killing the
62
77
  * git subprocess instead of letting a stale repository's diff land later.
63
78
  */
@@ -67,11 +82,15 @@ export async function loadGitDiff(cwd: string, argument: string, signal?: AbortS
67
82
  const text = await executeGit(cwd, spec.args, signal)
68
83
  return { title: `git diff - ${spec.label}`, files: parseGitDiffFiles(text) }
69
84
  } catch (error: unknown) {
70
- // An unborn repository has no HEAD. Preserve useful unstaged output for
71
- // the default form while still surfacing all other Git failures.
72
- if (argument.trim() !== '') throw error
73
- const text = await executeGit(cwd, ['diff', '--no-ext-diff', '--unified=3', '--'], signal)
74
- return { title: 'git diff - working tree', files: parseGitDiffFiles(text) }
85
+ // Only a repository without commits (no HEAD to diff against) may
86
+ // narrow the default form to the unstaged fallback. Every other
87
+ // failure output past the buffer limit, a corrupt index, a missing
88
+ // repository, or the caller aborting between the two calls — must
89
+ // surface, not silently shrink what /diff and /review end up seeing
90
+ // (an aborted probe would otherwise masquerade as an unborn repo).
91
+ if (argument.trim() !== '' || signal?.aborted === true || (await hasHeadRevision(cwd, signal))) throw error
92
+ const text = await executeGit(cwd, UNSTAGED_DIFF_ARGS, signal)
93
+ return { title: 'git diff - working tree (no commits yet)', files: parseGitDiffFiles(text) }
75
94
  }
76
95
  }
77
96
 
package/src/history.ts CHANGED
@@ -42,16 +42,25 @@ export function parseHistoryFile(raw: string, max = HISTORY_MAX_ENTRIES): readon
42
42
  }
43
43
 
44
44
  /**
45
- * Append one entry to the persistent file content: JSON line, capped to the
46
- * newest `max` entries with a trailing newline.
47
- * @param current - existing file content.
48
- * @param text - submission to persist.
49
- * @param max - entry cap.
50
- * @returns the new file content.
45
+ * The append unit for the persistent file: one JSON line, so a multi-line
46
+ * draft still occupies exactly one physical line. Each submission appends
47
+ * this unit at the end of the file, so concurrent terminals add entries
48
+ * after each other. Node chunks one append at 512 KiB: a pasted entry
49
+ * beyond that size could interleave mid-line with another writer's
50
+ * chunks, and the damaged line then drops out at the next parse —
51
+ * recall tolerates the loss by design.
51
52
  */
52
- export function appendHistoryContent(current: string, text: string, max = HISTORY_MAX_ENTRIES): string {
53
- const entries = [...parseHistoryFile(current, max), text].slice(-max)
54
- return entries.map(serializeHistoryEntry).join('\n') + '\n'
53
+ export function historyLine(text: string): string {
54
+ return serializeHistoryEntry(text) + '\n'
55
+ }
56
+
57
+ /**
58
+ * Whether the file on disk differs from its canonical form (deduped and
59
+ * capped). True means stale lines have accumulated and the next boot
60
+ * should rewrite it once, atomically.
61
+ */
62
+ export function needsCompaction(raw: string, max = HISTORY_MAX_ENTRIES): boolean {
63
+ return serializeHistoryList(parseHistoryFile(raw, max)) !== raw
55
64
  }
56
65
 
57
66
  /**
@@ -70,10 +79,10 @@ export function recordLocalEntry(local: readonly string[], text: string, max = H
70
79
  }
71
80
 
72
81
  /**
73
- * Serialize a capped entry list to the history file format (one JSON line per
74
- * entry, trailing newline). The runner writes the in-memory list as the whole
75
- * file, so rapid same-process submissions cannot lose entries to a
76
- * read-modify-write race (the file is never read back before writing).
82
+ * Serialize a capped entry list to the history file format (one JSON line
83
+ * per entry, trailing newline). The boot-time compaction writes this
84
+ * canonical form once when stale lines have accumulated; submissions
85
+ * themselves only ever append a single line.
77
86
  * @param entries - the entries to persist, oldest first.
78
87
  * @returns the file content, '' for an empty list.
79
88
  */