dsh-code 1.0.5 → 1.0.7

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 (47) hide show
  1. package/README.en.md +338 -286
  2. package/README.md +68 -16
  3. package/bin/deepseek.mjs +204 -4
  4. package/cordis.patch.yml +105 -7
  5. package/lib/index.mjs +2148 -439
  6. package/lib/session-query.mjs +149 -0
  7. package/lib/types/app.d.ts +34 -8
  8. package/lib/types/attachments.d.ts +36 -4
  9. package/lib/types/index.d.ts +38 -2
  10. package/lib/types/kernel-panels.d.ts +23 -0
  11. package/lib/types/provider-settings.d.ts +6 -11
  12. package/lib/types/render/animations.d.ts +74 -7
  13. package/lib/types/render/editor.d.ts +4 -3
  14. package/lib/types/render/export.d.ts +0 -6
  15. package/lib/types/render/fuzzy.d.ts +21 -0
  16. package/lib/types/render/ime-cursor.d.ts +60 -0
  17. package/lib/types/render/projection.d.ts +80 -4
  18. package/lib/types/render/status.d.ts +1 -1
  19. package/lib/types/session-directory.d.ts +48 -13
  20. package/lib/types/session-query.d.ts +92 -0
  21. package/lib/types/store.d.ts +3 -0
  22. package/lib/types/terminal-title.d.ts +58 -0
  23. package/lib/types/update-panel.d.ts +49 -0
  24. package/lib/types/update.d.ts +66 -0
  25. package/package.json +307 -162
  26. package/src/app.ts +730 -266
  27. package/src/attachments.ts +110 -11
  28. package/src/commands.ts +35 -5
  29. package/src/index.ts +1986 -1779
  30. package/src/internals.ts +66 -40
  31. package/src/kernel-panels.ts +89 -3
  32. package/src/provider-settings.ts +12 -12
  33. package/src/render/animations.ts +606 -403
  34. package/src/render/editor.ts +5 -4
  35. package/src/render/export.ts +13 -3
  36. package/src/render/fuzzy.ts +83 -0
  37. package/src/render/ime-cursor.ts +147 -0
  38. package/src/render/projection.ts +1974 -1621
  39. package/src/render/status.ts +18 -4
  40. package/src/session-directory.ts +94 -16
  41. package/src/session-query.ts +235 -0
  42. package/src/skills.ts +23 -9
  43. package/src/store.ts +39 -1
  44. package/src/subagents.ts +26 -3
  45. package/src/terminal-title.ts +173 -0
  46. package/src/update-panel.ts +246 -0
  47. package/src/update.ts +110 -0
@@ -487,12 +487,13 @@ export function composerMaxRows(terminalRows: number): number {
487
487
  }
488
488
 
489
489
  /**
490
- * History navigation starts with Up on an empty draft, or after visual
491
- * movement has reached the directional text edge of an unchanged recalled
492
- * entry. Every other position remains under textarea movement.
490
+ * History navigation starts with Up on an empty draft, or continues from an
491
+ * unchanged recalled entry whenever the caret sits on either text edge
492
+ * (start or end) - moving the caret into the interior returns the keys to
493
+ * ordinary editing until an edge is reached again.
493
494
  */
494
495
  export function shouldRecallNavigate(value: string, cursor: number, lastRecalled: string | null, direction: -1 | 1): boolean {
495
496
  if (value === '') return direction < 0
496
497
  if (lastRecalled !== value) return false
497
- return direction < 0 ? cursor === 0 : cursor === value.length
498
+ return cursor === 0 || cursor === value.length
498
499
  }
@@ -7,7 +7,7 @@
7
7
  */
8
8
 
9
9
  import { assertNever } from '@deepseek-ai/dsh-util-values'
10
- import { imageLabels, type TranscriptView } from './projection.ts'
10
+ import { fileLabels, imageLabels, type TranscriptView } from './projection.ts'
11
11
 
12
12
  /**
13
13
  * Render the transcript as a standalone markdown document.
@@ -15,6 +15,10 @@ import { imageLabels, type TranscriptView } from './projection.ts'
15
15
  * @param sessionId - the full session identity for the header.
16
16
  * @returns the complete markdown text.
17
17
  */
18
+ /** Both user and queued rows export the same attachment label block. */
19
+ const attachmentLabels = (entry: { images?: readonly unknown[]; files?: readonly unknown[] }): string =>
20
+ [imageLabels(entry.images as never), fileLabels(entry.files as never)].filter(label => label !== '').join('\n')
21
+
18
22
  export function buildExportMarkdown(view: TranscriptView, sessionId: string): string {
19
23
  const out: string[] = [
20
24
  view.title === ''
@@ -23,13 +27,19 @@ export function buildExportMarkdown(view: TranscriptView, sessionId: string): st
23
27
  `> session ${sessionId}`,
24
28
  '',
25
29
  ]
30
+ // The effective system prompt (v3 surface nodes) heads the export in a
31
+ // collapsed block: visible when audited, out of the way when scrolled.
32
+ if (view.systemPrompt !== '') {
33
+ out.push('<details><summary>system prompt</summary>', '', view.systemPrompt, '', '</details>', '')
34
+ }
26
35
  for (const entry of view.entries) {
27
36
  switch (entry.kind) {
28
37
  case 'user':
29
38
  if (entry.notice) {
30
39
  out.push(`> ⤷ context: ${entry.text}`, '')
31
40
  } else {
32
- out.push('## user', '', entry.text, ...(imageLabels(entry.images) === '' ? [] : [imageLabels(entry.images)]), '')
41
+ const attachments = attachmentLabels(entry)
42
+ out.push('## user', '', entry.text, ...(attachments === '' ? [] : [attachments]), '')
33
43
  }
34
44
  break
35
45
  case 'assistant':
@@ -68,7 +78,7 @@ export function buildExportMarkdown(view: TranscriptView, sessionId: string): st
68
78
  break
69
79
  case 'pending':
70
80
  // Codex PendingSteer: queued prompts export like ordinary user rows.
71
- out.push('## user', '', entry.text, ...(imageLabels(entry.images) === '' ? [] : [imageLabels(entry.images)]), '')
81
+ out.push('## user', '', entry.text, ...(attachmentLabels(entry) === '' ? [] : [attachmentLabels(entry)]), '')
72
82
  break
73
83
  default:
74
84
  assertNever(entry, 'transcript entry kind')
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Shared fuzzy ranking for `/` and `@` menu candidates: the query must be a
3
+ * case-insensitive ordered subsequence of the candidate name. Prefix hits
4
+ * rank first, then the strongest alignment score, then the source order of
5
+ * the input. Ported from the upstream web client's ui-primitives
6
+ * (rank-by-name.ts) so the terminal matches the web menu's discovery feel;
7
+ * the algorithm is unchanged, only the module's home moved.
8
+ *
9
+ * @module @deepseek-ai/dsh-code/render/fuzzy
10
+ */
11
+
12
+ /** One match with its stable source position. */
13
+ interface Ranked<T> {
14
+ readonly item: T
15
+ readonly index: number
16
+ readonly prefix: boolean
17
+ readonly score: number
18
+ }
19
+
20
+ /** Extra weight for name starts and separator boundaries. */
21
+ function boundaryBonus(name: string, index: number): number {
22
+ return index === 0 || name.charAt(index - 1) === '-' || name.charAt(index - 1) === '_' ? 8 : 0
23
+ }
24
+
25
+ /**
26
+ * Score the strongest ordered-subsequence alignment in O(name × query).
27
+ * Boundary and adjacent matches earn weight; skipped and leading characters
28
+ * cost weight. Undefined when the query is not a subsequence of the name.
29
+ */
30
+ function alignmentScore(name: string, query: string): number | undefined {
31
+ if (query.length > name.length) return undefined
32
+ const noMatch = Number.NEGATIVE_INFINITY
33
+ let previous = Array<number>(name.length).fill(noMatch)
34
+ for (let index = 0; index < name.length; index++) {
35
+ if (name.charAt(index) === query.charAt(0)) previous[index] = 1 + boundaryBonus(name, index) - index
36
+ }
37
+ for (let queryIndex = 1; queryIndex < query.length; queryIndex++) {
38
+ const current = Array<number>(name.length).fill(noMatch)
39
+ // Sweep the previous row once: `left` is its score one character back
40
+ // (the adjacent continuation), `leftLeft` two back (the earliest gapped one).
41
+ let left = noMatch
42
+ let leftLeft = noMatch
43
+ let bestGapped = noMatch
44
+ for (const [index, prior] of previous.entries()) {
45
+ if (leftLeft !== noMatch) bestGapped = Math.max(bestGapped, leftLeft + index - 2)
46
+ if (name.charAt(index) === query.charAt(queryIndex)) {
47
+ const bonus = 1 + boundaryBonus(name, index)
48
+ let score = noMatch
49
+ if (left !== noMatch) score = left + bonus + 4
50
+ if (bestGapped !== noMatch) score = Math.max(score, bestGapped + bonus + 1 - index)
51
+ current[index] = score
52
+ }
53
+ leftLeft = left
54
+ left = prior
55
+ }
56
+ previous = current
57
+ }
58
+ let best = noMatch
59
+ for (const score of previous) best = Math.max(best, score)
60
+ return best === noMatch ? undefined : best
61
+ }
62
+
63
+ /**
64
+ * Rank named items by a menu query.
65
+ * @param items - candidates in source order (the caller's composition order
66
+ * is the final tie-breaker, e.g. local commands before registry entries).
67
+ * @param rawQuery - the text typed after the trigger, matched case-insensitively.
68
+ * @returns the matching items: prefix hits first, then by alignment score,
69
+ * then in source order. The input list itself for an empty query.
70
+ */
71
+ export function rankByName<T extends { readonly name: string }>(items: readonly T[], rawQuery: string): readonly T[] {
72
+ const query = rawQuery.toLowerCase()
73
+ if (query === '') return items
74
+ const ranked: Ranked<T>[] = []
75
+ items.forEach((item, index) => {
76
+ const name = item.name.toLowerCase()
77
+ const score = alignmentScore(name, query)
78
+ if (score !== undefined) ranked.push({ item, index, prefix: name.startsWith(query), score })
79
+ })
80
+ ranked.sort((left, right) =>
81
+ Number(right.prefix) - Number(left.prefix) || right.score - left.score || left.index - right.index)
82
+ return ranked.map(match => match.item)
83
+ }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * IME cursor anchoring for the composer.
3
+ *
4
+ * Ink keeps the real terminal cursor hidden and parks it just below the
5
+ * dynamic tree (after the status row). IME composition text and candidate
6
+ * windows anchor to that real cursor cell - VS Code's integrated terminal
7
+ * positions its hidden IME textarea there, and Windows consoles behave the
8
+ * same - so CJK input appeared at the bottom of the screen instead of at the
9
+ * caret. The anchor moves the real cursor onto the caret cell without
10
+ * touching Ink's relative erase ledger:
11
+ *
12
+ * - the displacement is owned: before any foreign write or re-anchor, the
13
+ * wrapper cancels it (cursor down, column 1), so every writer - Ink's
14
+ * log-update rewrites, the resize replay, protocol pushes - keeps seeing
15
+ * the cursor exactly where it was left;
16
+ * - log-update frame chunks (which start with the erase-line sequence) get
17
+ * the anchor re-appended inside the same write, so a repaint can never
18
+ * leave the cursor behind - in particular the caret blink keeps the anchor
19
+ * stable while an IME composition is open, because the terminal parses the
20
+ * rewrite and the re-anchor as one atomic update.
21
+ *
22
+ * @module @deepseek-ai/dsh-code/render/ime-cursor
23
+ */
24
+
25
+ import { useEffect, useRef } from 'react'
26
+ import { useStdout } from 'ink'
27
+
28
+ /** Cancel `rows` of owned upward displacement and return to column 1. */
29
+ export function imeCursorRestore(rows: number): string {
30
+ return rows > 0 ? `\x1b[${rows}B\r` : ''
31
+ }
32
+
33
+ /** Move onto the caret cell: `rows` up from Ink's parked row, 0-based column. */
34
+ export function imeCursorMove(rows: number, column: number): string {
35
+ return rows > 0 ? `\x1b[${rows}A\x1b[${column + 1}G` : ''
36
+ }
37
+
38
+ /**
39
+ * Rows between the caret cell and Ink's parked cursor row: the band's bottom
40
+ * blank row, the editor window rows below the caret, the status footer, and
41
+ * Ink's own below-frame row. Rows above the composer (gutter, live content)
42
+ * never enter this distance.
43
+ */
44
+ export function imeCursorRowsUp(input: {
45
+ editorWindowRows: number
46
+ caretRowInWindow: number
47
+ rowsBelowComposer: number
48
+ }): number {
49
+ return Math.max(1, input.editorWindowRows - input.caretRowInWindow + input.rowsBelowComposer)
50
+ }
51
+
52
+ /** log-update chunks start with the erase-line sequence; a chunk without it
53
+ * leaves no frame behind, so the next commit re-applies the anchor. */
54
+ const FRAME_CHUNK_MARKER = '\x1b[2K'
55
+
56
+ /** The installed anchor handle. */
57
+ export interface ImeCursorAnchor {
58
+ /** Anchor on the caret cell: `rows` above Ink's parked row at the 0-based
59
+ * `column`; `rows <= 0` releases the anchor. */
60
+ anchor(rows: number, column: number): void
61
+ /** Cancel the displacement, restore the original write path, and detach. */
62
+ release(): void
63
+ }
64
+
65
+ const ANCHOR_STATE = Symbol.for('dsh-code.ime-cursor-anchor')
66
+
67
+ /**
68
+ * Take over `stream.write` so the anchor displacement stays invisible to every
69
+ * other writer. Idempotent per stream: a second install returns the live
70
+ * handle. Returns `undefined` on non-TTY streams where anchoring is meaningless.
71
+ */
72
+ export function installImeCursorAnchor(stream: NodeJS.WriteStream): ImeCursorAnchor | undefined {
73
+ if (stream?.isTTY !== true) return undefined
74
+ const target = stream as NodeJS.WriteStream & { [ANCHOR_STATE]?: ImeCursorAnchor | undefined }
75
+ const installed = target[ANCHOR_STATE]
76
+ if (installed !== undefined) return installed
77
+ const originalWrite = target.write.bind(target) as (...args: unknown[]) => unknown
78
+ let rows = 0
79
+ let column = -1
80
+ let detached = false
81
+ const anchor: ImeCursorAnchor = {
82
+ anchor(nextRows, nextColumn) {
83
+ if (detached) return
84
+ if (nextRows <= 0) {
85
+ if (rows !== 0) originalWrite(imeCursorRestore(rows))
86
+ rows = 0
87
+ column = -1
88
+ return
89
+ }
90
+ if (rows === nextRows && column === nextColumn) return
91
+ originalWrite(imeCursorRestore(rows) + imeCursorMove(nextRows, nextColumn))
92
+ rows = nextRows
93
+ column = nextColumn
94
+ },
95
+ release() {
96
+ if (detached) return
97
+ detached = true
98
+ if (rows !== 0) originalWrite(imeCursorRestore(rows))
99
+ rows = 0
100
+ column = -1
101
+ target.write = originalWrite as typeof target.write
102
+ delete target[ANCHOR_STATE]
103
+ },
104
+ }
105
+ target.write = ((chunk: unknown, ...rest: unknown[]) => {
106
+ const ownedRows = rows
107
+ const ownedColumn = column
108
+ if (ownedRows === 0 || typeof chunk !== 'string') {
109
+ if (ownedRows !== 0) {
110
+ originalWrite(imeCursorRestore(ownedRows))
111
+ rows = 0
112
+ column = -1
113
+ }
114
+ return originalWrite(chunk, ...rest)
115
+ }
116
+ const reanchor = chunk.startsWith(FRAME_CHUNK_MARKER)
117
+ rows = reanchor ? ownedRows : 0
118
+ column = reanchor ? ownedColumn : -1
119
+ return originalWrite(imeCursorRestore(ownedRows) + chunk + (reanchor ? imeCursorMove(ownedRows, ownedColumn) : ''), ...rest)
120
+ }) as typeof target.write
121
+ target[ANCHOR_STATE] = anchor
122
+ return anchor
123
+ }
124
+
125
+ /**
126
+ * Keep the real terminal cursor on the composer's caret cell while `active`
127
+ * (the editable composer). The second effect runs after every commit without
128
+ * a dep list: frame rewrites restore the anchor themselves, but any other
129
+ * write (protocol push, resize replay) leaves the cursor at Ink's parked
130
+ * position, and the next commit re-anchors it.
131
+ */
132
+ export function useImeCursorAnchor(active: boolean, rows: number, column: number): void {
133
+ const { stdout } = useStdout()
134
+ const anchorRef = useRef<ImeCursorAnchor | undefined>(undefined)
135
+ useEffect(() => {
136
+ if (stdout === undefined) return undefined
137
+ const installed = installImeCursorAnchor(stdout)
138
+ anchorRef.current = installed
139
+ return () => {
140
+ installed?.release()
141
+ if (anchorRef.current === installed) anchorRef.current = undefined
142
+ }
143
+ }, [stdout])
144
+ useEffect(() => {
145
+ anchorRef.current?.anchor(active && rows > 0 ? rows : 0, column)
146
+ })
147
+ }