dsh-code 0.2.0 → 0.3.0

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.
@@ -0,0 +1,235 @@
1
+ /**
2
+ * Terminal markdown renderer for assistant replies: a pure GFM-subset
3
+ * block/inline parser producing styled line segments the Ink renderer maps
4
+ * to colored text. No ANSI here — the app owns color mapping, tests own the
5
+ * structure. The subset mirrors what agent replies actually emit: headings,
6
+ * emphasis, inline/fenced code, flat lists, blockquotes, links, rules, and
7
+ * wrapped paragraphs. Unknown syntax degrades to plain text (never throws).
8
+ *
9
+ * @module @deepseek-ai/dsh-code/render/markdown
10
+ */
11
+
12
+ /** Style classes the renderer emits; the app maps them to colors/props. */
13
+ export type MdStyle = 'plain' | 'bold' | 'italic' | 'boldItalic' | 'code' | 'accent' | 'dim' | 'strike'
14
+
15
+ /** One styled run of text. */
16
+ export interface MdSegment {
17
+ /** Visible text (no ANSI). */
18
+ text: string
19
+ /** Presentation class for the app's color map. */
20
+ style: MdStyle
21
+ }
22
+
23
+ /** One rendered line: a sequence of styled runs. */
24
+ export interface MdLine {
25
+ segments: readonly MdSegment[]
26
+ }
27
+
28
+ /** Plain segment helper. */
29
+ function seg(text: string, style: MdStyle = 'plain'): MdSegment {
30
+ return { text, style }
31
+ }
32
+
33
+ /** Visible width of a run in columns (CJK counts double). */
34
+ export function visibleColumns(text: string): number {
35
+ let columns = 0
36
+ for (const char of text) {
37
+ const code = char.codePointAt(0) ?? 0
38
+ columns += code > 0x2e7f ? 2 : 1
39
+ }
40
+ return columns
41
+ }
42
+
43
+ /** Walk a segment list, breaking it into lines that fit `width` columns. */
44
+ function wrapSegments(segments: readonly MdSegment[], width: number): readonly MdSegment[][] {
45
+ const lines: MdSegment[][] = []
46
+ let current: MdSegment[] = []
47
+ let used = 0
48
+ for (const segment of segments) {
49
+ // Break the segment at spaces into words so long runs wrap mid-text.
50
+ const words = segment.text.split(/( )/u)
51
+ for (const word of words) {
52
+ if (word === '') continue
53
+ const columns = visibleColumns(word)
54
+ if (used + columns > width && used > 0) {
55
+ lines.push(current)
56
+ current = []
57
+ used = 0
58
+ }
59
+ // A single word wider than the line still goes on its own line.
60
+ current.push({ text: word, style: segment.style })
61
+ used += columns
62
+ }
63
+ }
64
+ if (current.length > 0) lines.push(current)
65
+ // Drop the trailing space a wrapped line picked up before the break.
66
+ return lines.map(line => {
67
+ const last = line[line.length - 1]
68
+ if (last !== undefined && last.text === ' ' && line.length > 1) return line.slice(0, -1)
69
+ return line
70
+ })
71
+ }
72
+
73
+ /** Join adjacent same-style runs so the app renders fewer elements. */
74
+ function merge(segments: readonly MdSegment[]): readonly MdSegment[] {
75
+ const merged: MdSegment[] = []
76
+ for (const segment of segments) {
77
+ const last = merged[merged.length - 1]
78
+ if (last !== undefined && last.style === segment.style) {
79
+ merged[merged.length - 1] = { text: last.text + segment.text, style: last.style }
80
+ } else {
81
+ merged.push({ ...segment })
82
+ }
83
+ }
84
+ return merged
85
+ }
86
+
87
+ /** One parsed inline run before wrapping. */
88
+ interface InlineRun {
89
+ text: string
90
+ style: MdStyle
91
+ }
92
+
93
+ /**
94
+ * Parse inline markdown in one line of text. Link destinations render as a
95
+ * dim `(url)` suffix — the visible text keeps the accent.
96
+ */
97
+ function parseInline(text: string): readonly InlineRun[] {
98
+ const runs: InlineRun[] = []
99
+ let rest = text
100
+ while (rest !== '') {
101
+ const code = /^`([^`]+)`/u.exec(rest)
102
+ if (code !== null) {
103
+ runs.push({ text: code[1] ?? '', style: 'code' })
104
+ rest = rest.slice(code[0].length)
105
+ continue
106
+ }
107
+ const boldItalic = /^\*\*\*([^*]+)\*\*\*/u.exec(rest)
108
+ if (boldItalic !== null) {
109
+ runs.push({ text: boldItalic[1] ?? '', style: 'boldItalic' })
110
+ rest = rest.slice(boldItalic[0].length)
111
+ continue
112
+ }
113
+ const bold = /^\*\*([^*]+)\*\*/u.exec(rest)
114
+ if (bold !== null) {
115
+ runs.push({ text: bold[1] ?? '', style: 'bold' })
116
+ rest = rest.slice(bold[0].length)
117
+ continue
118
+ }
119
+ const italic = /^\*([^*]+)\*/u.exec(rest) ?? /^_([^_]+)_/u.exec(rest)
120
+ if (italic !== null) {
121
+ runs.push({ text: italic[1] ?? '', style: 'italic' })
122
+ rest = rest.slice(italic[0].length)
123
+ continue
124
+ }
125
+ const strike = /^~~([^~]+)~~/u.exec(rest)
126
+ if (strike !== null) {
127
+ runs.push({ text: strike[1] ?? '', style: 'strike' })
128
+ rest = rest.slice(strike[0].length)
129
+ continue
130
+ }
131
+ const link = /^\[([^\]]+)\]\(([^)\s]+)\)/u.exec(rest)
132
+ if (link !== null) {
133
+ const label = link[1] ?? ''
134
+ const url = link[2] ?? ''
135
+ runs.push({ text: label, style: 'accent' })
136
+ runs.push({ text: ` (${url})`, style: 'dim' })
137
+ rest = rest.slice(link[0].length)
138
+ continue
139
+ }
140
+ // Plain run up to the next special opener.
141
+ const next = rest.search(/[*_`~[]/u)
142
+ if (next === -1) {
143
+ runs.push({ text: rest, style: 'plain' })
144
+ break
145
+ }
146
+ if (next > 0) {
147
+ runs.push({ text: rest.slice(0, next), style: 'plain' })
148
+ rest = rest.slice(next)
149
+ continue
150
+ }
151
+ // A special opener at position 0 that no pattern consumed: emit it
152
+ // literally and advance, so unbalanced syntax never loops.
153
+ runs.push({ text: rest.slice(0, 1), style: 'plain' })
154
+ rest = rest.slice(1)
155
+ }
156
+ return runs
157
+ }
158
+
159
+ const HEADING = /^(#{1,6})\s+(.*)$/u
160
+ const FENCE = /^```([^\s`]*)\s*$/u
161
+ const RULE = /^(?:---|\*\*\*|___)\s*$/u
162
+ const QUOTE = /^>\s?(.*)$/u
163
+ const UNORDERED = /^\s*[-*+]\s+(.*)$/u
164
+ const ORDERED = /^\s*(\d+)[.)]\s+(.*)$/u
165
+
166
+ /** Render markdown text into styled lines of at most `width` columns. */
167
+ export function renderMarkdown(text: string, width: number): readonly MdLine[] {
168
+ const lines: MdLine[] = []
169
+ const push = (segments: readonly MdSegment[]): void => {
170
+ for (const wrapped of wrapSegments(segments, Math.max(10, width))) {
171
+ lines.push({ segments: merge(wrapped) })
172
+ }
173
+ }
174
+ const raw = text.replaceAll('\r', '')
175
+ const source = raw.split('\n')
176
+ let index = 0
177
+ while (index < source.length) {
178
+ const line = source[index] ?? ''
179
+ index += 1
180
+
181
+ // Fenced code block: verbatim lines in code style, language label first.
182
+ const fence = FENCE.exec(line)
183
+ if (fence !== null) {
184
+ const language = fence[1] ?? ''
185
+ if (language !== '') push([seg(` ${language}`, 'dim')])
186
+ while (index < source.length && !FENCE.test(source[index] ?? '')) {
187
+ push([seg(` ${source[index] ?? ''}`, 'code')])
188
+ index += 1
189
+ }
190
+ index += 1 // closing fence
191
+ continue
192
+ }
193
+
194
+ if (line.trim() === '') continue
195
+ if (RULE.test(line.trim())) {
196
+ push([seg(` ${'─'.repeat(Math.max(1, Math.floor(width / 4)))}`, 'dim')])
197
+ continue
198
+ }
199
+ const heading = HEADING.exec(line)
200
+ if (heading !== null) {
201
+ push([seg(heading[2] ?? '', 'accent')])
202
+ continue
203
+ }
204
+ const quote = QUOTE.exec(line)
205
+ if (quote !== null) {
206
+ push([seg(' │ ', 'accent'), ...parseInline(quote[1] ?? '').map(run => seg(run.text, run.style === 'plain' ? 'dim' : run.style))])
207
+ continue
208
+ }
209
+ const ordered = ORDERED.exec(line)
210
+ if (ordered !== null) {
211
+ push([seg(` ${ordered[1] ?? ''}. `, 'accent'), ...parseInline(ordered[2] ?? '').map(run => seg(run.text, run.style))])
212
+ continue
213
+ }
214
+ const unordered = UNORDERED.exec(line)
215
+ if (unordered !== null) {
216
+ push([seg(' • ', 'accent'), ...parseInline(unordered[1] ?? '').map(run => seg(run.text, run.style))])
217
+ continue
218
+ }
219
+
220
+ // Paragraph: gather until a blank line, then wrap as one flow. Line
221
+ // breaks inside a paragraph join as a single space (GFM soft breaks).
222
+ const paragraph = [line]
223
+ while (index < source.length && (source[index] ?? '').trim() !== '') {
224
+ paragraph.push(source[index] ?? '')
225
+ index += 1
226
+ }
227
+ const runs: InlineRun[] = []
228
+ for (let at = 0; at < paragraph.length; at += 1) {
229
+ if (at > 0) runs.push({ text: ' ', style: 'plain' })
230
+ runs.push(...parseInline(paragraph[at] ?? ''))
231
+ }
232
+ push(runs.map(run => seg(run.text, run.style)))
233
+ }
234
+ return lines
235
+ }
@@ -10,14 +10,21 @@
10
10
  import { boundContextSummary, type ContentBlock } from '@deepseek-ai/dsh-llm'
11
11
  import type { SessionEvent, TodoItem } from '@deepseek-ai/dsh-session'
12
12
  // Type-only imports merge the plugin-owned SessionEventMap variants (command/*
13
- // from dsh-commands) into the union this reducer switches on.
13
+ // from dsh-commands, plan/mode, permission/preset) into the union this reducer
14
+ // switches on.
14
15
  import type {} from '@deepseek-ai/dsh-commands'
16
+ import type {} from '@deepseek-ai/dsh-plan-mode'
17
+ import type {} from '@deepseek-ai/dsh-permission-presets'
18
+ import { toolArgumentsPreview } from './tool-preview.ts'
15
19
 
16
20
  /** One user prompt line. */
17
21
  export interface UserEntry {
18
22
  kind: 'user'
19
23
  /** Joined text blocks of the user message. */
20
24
  text: string
25
+ /** True for collapsed injected context (plugin/continuation notices), which
26
+ * the renderer marks with a dim ↳ instead of the user ❯ prompt. */
27
+ notice: boolean
21
28
  }
22
29
 
23
30
  /** One assembled assistant reply. */
@@ -25,6 +32,8 @@ export interface AssistantEntry {
25
32
  kind: 'assistant'
26
33
  /** Joined text blocks of the assistant message. */
27
34
  text: string
35
+ /** Joined reasoning blocks of the same message, empty when the model thought out loud. */
36
+ reasoning: string
28
37
  }
29
38
 
30
39
  /** One model-requested tool invocation and its settled state. */
@@ -36,6 +45,8 @@ export interface ToolEntry {
36
45
  name: string
37
46
  /** Raw arguments JSON string exactly as the model produced it. */
38
47
  arguments: string
48
+ /** Bounded human-meaningful arguments preview for the tool card. */
49
+ preview: string
39
50
  /** Execution state; `running` until the paired result lands. */
40
51
  state: 'running' | 'done' | 'error'
41
52
  /** Bounded first text block of the result, empty until it lands. */
@@ -97,6 +108,8 @@ export interface TranscriptView {
97
108
  entries: readonly TranscriptEntry[]
98
109
  /** Text accumulated from `assistant/chunk` deltas since the last flush. */
99
110
  streaming: string
111
+ /** Thinking accumulated from `assistant/chunk` reasoning deltas since the last flush. */
112
+ streamingReasoning: string
100
113
  /** Latest whole-list todo snapshot from `todo/write`, empty when none. */
101
114
  todos: readonly TodoItem[]
102
115
  /** True while a durable turn is open (`turn/start` … `turn/end`). */
@@ -110,6 +123,10 @@ export interface TranscriptView {
110
123
  * Empty before the session's first request.
111
124
  */
112
125
  model: string
126
+ /** Plan mode state folded from the last `plan/mode` event. */
127
+ plan: boolean
128
+ /** Active permission preset folded from the last `permission/preset` event, empty before one. */
129
+ permission: string
113
130
  /**
114
131
  * Fold-internal timing anchors, never rendered: open step and tool-call
115
132
  * start timestamps the next `assistant/message` / `tool/result` resolves
@@ -123,14 +140,22 @@ function textOf(content: readonly ContentBlock[]): string {
123
140
  return content.filter(block => block.type === 'text').map(block => block.text).join('')
124
141
  }
125
142
 
143
+ /** Join the reasoning blocks of a content list; non-reasoning blocks contribute nothing. */
144
+ function reasoningOf(content: readonly ContentBlock[]): string {
145
+ return content.filter(block => block.type === 'reasoning').map(block => block.text).join('')
146
+ }
147
+
126
148
  /** A fresh, empty transcript view. */
127
149
  export function createTranscriptView(): TranscriptView {
128
150
  return {
129
151
  entries: [],
130
152
  streaming: '',
153
+ streamingReasoning: '',
131
154
  todos: [],
132
155
  busy: false,
133
156
  model: '',
157
+ plan: false,
158
+ permission: '',
134
159
  stats: { turns: 0, steps: 0, llmMs: 0, toolMs: 0, usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 } },
135
160
  anchors: { stepStart: new Map(), toolStart: new Map() },
136
161
  }
@@ -150,20 +175,25 @@ export function projectEvent(view: TranscriptView, event: SessionEvent): Transcr
150
175
  // elsewhere in the product; only direct human prompts render in full.
151
176
  const message = event.data
152
177
  if (message.source.kind === 'user') {
153
- return { ...view, entries: [...view.entries, { kind: 'user', text: textOf(message.content) }] }
178
+ return { ...view, entries: [...view.entries, { kind: 'user', text: textOf(message.content), notice: false }] }
154
179
  }
155
180
  const notice = message.source.kind === 'plugin' && message.source.form === 'notice'
156
181
  ? message.source.summary
157
182
  : message.source.kind
158
- return { ...view, entries: [...view.entries, { kind: 'user', text: boundContextSummary(notice) }] }
183
+ return { ...view, entries: [...view.entries, { kind: 'user', text: boundContextSummary(notice), notice: true }] }
159
184
  }
160
185
  case 'assistant/chunk': {
161
186
  const chunk = event.data.chunk
162
- if (chunk.type !== 'text-delta') return view
163
- return { ...view, streaming: view.streaming + chunk.text }
187
+ if (chunk.type === 'text-delta') {
188
+ return { ...view, streaming: view.streaming + chunk.text }
189
+ }
190
+ if (chunk.type === 'reasoning-delta') {
191
+ return { ...view, streamingReasoning: view.streamingReasoning + chunk.text }
192
+ }
193
+ return view
164
194
  }
165
195
  case 'assistant/message': {
166
- // The assembled message is authoritative; drop the streamed buffer.
196
+ // The assembled message is authoritative; drop the streamed buffers.
167
197
  const key = `${event.data.turn}:${event.data.step}`
168
198
  const started = view.anchors.stepStart.get(key)
169
199
  view.anchors.stepStart.delete(key)
@@ -172,7 +202,12 @@ export function projectEvent(view: TranscriptView, event: SessionEvent): Transcr
172
202
  return {
173
203
  ...view,
174
204
  streaming: '',
175
- entries: [...view.entries, { kind: 'assistant', text: textOf(event.data.message.content) }],
205
+ streamingReasoning: '',
206
+ entries: [...view.entries, {
207
+ kind: 'assistant',
208
+ text: textOf(event.data.message.content),
209
+ reasoning: reasoningOf(event.data.message.content),
210
+ }],
176
211
  stats: {
177
212
  ...view.stats,
178
213
  llmMs: view.stats.llmMs + (started === undefined ? 0 : Math.max(0, event.time - started)),
@@ -194,6 +229,7 @@ export function projectEvent(view: TranscriptView, event: SessionEvent): Transcr
194
229
  callId: data.callId,
195
230
  name: data.name,
196
231
  arguments: data.arguments,
232
+ preview: toolArgumentsPreview(data.arguments, data.name),
197
233
  state: 'running',
198
234
  summary: '',
199
235
  }],
@@ -247,6 +283,11 @@ export function projectEvent(view: TranscriptView, event: SessionEvent): Transcr
247
283
  const config = event.data.header.config
248
284
  return { ...view, model: `${config.provider}/${config.model}` }
249
285
  }
286
+ case 'plan/mode':
287
+ // Whole-value replace; the last one wins (upstream fold semantics).
288
+ return { ...view, plan: event.data.active }
289
+ case 'permission/preset':
290
+ return { ...view, permission: event.data.preset }
250
291
  case 'command/run': {
251
292
  const data = event.data
252
293
  return {
@@ -57,6 +57,10 @@ export interface StatusFacts {
57
57
  branch: string
58
58
  /** Short session identifier (last dash-separated segment or tail). */
59
59
  sessionId: string
60
+ /** Whether plan mode is active (folded from `plan/mode`). */
61
+ plan: boolean
62
+ /** Active permission preset (folded from `permission/preset`), empty when unknown. */
63
+ permission: string
60
64
  }
61
65
 
62
66
  /**
@@ -67,8 +71,12 @@ export interface StatusFacts {
67
71
  */
68
72
  export function buildStatusGroups(facts: StatusFacts, stats: TranscriptStats): string[] {
69
73
  const groups: string[] = []
70
- const identity = [facts.model, facts.cwd, facts.branch === '' ? undefined : `⑂ ${facts.branch}`]
71
- .filter(part => part !== undefined && part !== '')
74
+ const identity = [
75
+ facts.model,
76
+ facts.cwd,
77
+ facts.branch === '' ? undefined : `⑂ ${facts.branch}`,
78
+ facts.plan ? '⧉ plan' : undefined,
79
+ ].filter(part => part !== undefined && part !== '')
72
80
  if (identity.length > 0) groups.push(identity.join(' · '))
73
81
  if (stats.turns > 0 || stats.steps > 0) {
74
82
  groups.push(`T${stats.turns} · S${stats.steps}`)
@@ -83,5 +91,9 @@ export function buildStatusGroups(facts: StatusFacts, stats: TranscriptStats): s
83
91
  groups.push(`↑${formatTokens(stats.usage.inputTokens)} ↓${formatTokens(stats.usage.outputTokens)}`)
84
92
  }
85
93
  if (facts.sessionId !== '') groups.push(facts.sessionId)
94
+ // The permission preset trails the line: switching it changes only the
95
+ // tail, so the left-aligned bar never shifts its other groups. Plain text,
96
+ // the Claude-Code permission-mode display (no glyphs).
97
+ if (facts.permission !== undefined && facts.permission !== '') groups.push(facts.permission)
86
98
  return groups
87
99
  }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Bounded preview line for a tool invocation's raw JSON arguments: the first
3
+ * human-meaningful string among the well-known keys (command, path, query, …)
4
+ * with a fallback to the bounded raw JSON. Shared by the tool card in the
5
+ * transcript and the approval bar's command preview.
6
+ *
7
+ * @module @deepseek-ai/dsh-code/render/tool-preview
8
+ */
9
+
10
+ /** Keys searched in declaration order when building a preview. */
11
+ const PREVIEW_KEYS = ['command', 'cmd', 'description', 'path', 'pattern', 'query'] as const
12
+
13
+ /**
14
+ * Resolve one bounded preview for raw tool arguments.
15
+ * @param args - raw JSON arguments string as the model produced it.
16
+ * @param toolName - the tool the arguments belong to (fallback label).
17
+ * @returns the preview line; empty when nothing useful resolves.
18
+ */
19
+ export function toolArgumentsPreview(args: string, toolName: string): string {
20
+ if (args === '') return toolName
21
+ try {
22
+ const parsed: unknown = JSON.parse(args)
23
+ if (parsed !== null && typeof parsed === 'object') {
24
+ const record = parsed as Record<string, unknown>
25
+ for (const key of PREVIEW_KEYS) {
26
+ const value = record[key]
27
+ if (typeof value === 'string' && value !== '') return value
28
+ }
29
+ }
30
+ } catch {
31
+ // Raw JSON parse failed: fall through to the bounded raw arguments.
32
+ }
33
+ return args.length > 80 ? `${args.slice(0, 77)}...` : args
34
+ }
package/src/theme.ts CHANGED
@@ -28,6 +28,10 @@ export const TUI_RGB = {
28
28
  error: [239, 68, 68],
29
29
  /** Warning amber — `--dsw-static-amber-500`. */
30
30
  warn: [245, 158, 11],
31
+ /** Default foreground text — `--dsw-static-neutral-50`. */
32
+ text: [236, 240, 246],
33
+ /** Inline/fenced code — soft sky blue, distinct from brand accents. */
34
+ code: [125, 211, 252],
31
35
  } as const satisfies Record<string, readonly [number, number, number]>
32
36
 
33
37
  /** Paint with the primary brand blue: whale, wordmark, tool names, accents. */