dsh-code 0.1.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.
Files changed (43) hide show
  1. package/README.md +17 -4
  2. package/README.zh.md +17 -4
  3. package/cordis.patch.yml +22 -5
  4. package/lib/index.mjs +1888 -100
  5. package/lib/invariant.mjs +1 -1
  6. package/lib/startup.mjs +70 -0
  7. package/lib/types/app.d.ts +64 -9
  8. package/lib/types/approval.d.ts +57 -0
  9. package/lib/types/commands.d.ts +37 -0
  10. package/lib/types/index.d.ts +19 -7
  11. package/lib/types/invariant.d.ts +2 -2
  12. package/lib/types/mentions.d.ts +70 -0
  13. package/lib/types/models.d.ts +37 -0
  14. package/lib/types/questions.d.ts +48 -0
  15. package/lib/types/render/animations.d.ts +15 -0
  16. package/lib/types/render/markdown.d.ts +27 -0
  17. package/lib/types/render/projection.d.ts +37 -3
  18. package/lib/types/render/status.d.ts +4 -0
  19. package/lib/types/render/text.d.ts +18 -0
  20. package/lib/types/render/tool-preview.d.ts +15 -0
  21. package/lib/types/skills.d.ts +45 -0
  22. package/lib/types/startup.d.ts +44 -0
  23. package/lib/types/store.d.ts +8 -2
  24. package/lib/types/theme.d.ts +4 -0
  25. package/package.json +36 -3
  26. package/src/app.ts +971 -57
  27. package/src/approval.ts +126 -0
  28. package/src/commands.ts +71 -0
  29. package/src/index.ts +353 -40
  30. package/src/invariant.ts +3 -3
  31. package/src/mentions.ts +193 -0
  32. package/src/models.ts +66 -0
  33. package/src/questions.ts +143 -0
  34. package/src/render/animations.ts +22 -0
  35. package/src/render/markdown.ts +235 -0
  36. package/src/render/projection.ts +117 -10
  37. package/src/render/status.ts +14 -2
  38. package/src/render/text.ts +24 -0
  39. package/src/render/tool-preview.ts +34 -0
  40. package/src/skills.ts +104 -0
  41. package/src/startup.ts +91 -0
  42. package/src/store.ts +10 -4
  43. package/src/theme.ts +4 -0
package/src/invariant.ts CHANGED
@@ -1,12 +1,12 @@
1
1
  /**
2
- * Package-owned invariant companion for `@deepseek-ai/dsh-tui`.
3
- * @module @deepseek-ai/dsh-tui/invariant
2
+ * Package-owned invariant companion for `dsh-code`.
3
+ * @module dsh-code/invariant
4
4
  */
5
5
 
6
6
  import type { Context } from '@deepseek-ai/cordis'
7
7
  import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
8
8
 
9
- const PACKAGE_NAME = '@deepseek-ai/dsh-tui'
9
+ const PACKAGE_NAME = 'dsh-code'
10
10
 
11
11
  /** Cordis companion plugin name. */
12
12
  export const name = 'tui-invariant'
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Workspace @mention support: file candidates from a bounded async scan of
3
+ * the session cwd, session candidates from the opt-in `sessionReferenceResolver`
4
+ * service, and submission preparation through its `prepare()` API. Picked
5
+ * session mentions land as canonical `@[label](dsh-session:…)` tokens; on
6
+ * submit the text is parsed back into readable `@label` text plus structured
7
+ * references, snapshots are injected via `agent.inject()` before the readable
8
+ * message wakes the driver (`followup` idle, `steer` running) — exactly the
9
+ * upstream README's wiring.
10
+ *
11
+ * @module @deepseek-ai/dsh-code/mentions
12
+ */
13
+
14
+ import { readdir } from 'node:fs/promises'
15
+ import { join } from 'node:path'
16
+ import type { Context } from '@deepseek-ai/cordis'
17
+ import type { Agent } from '@deepseek-ai/dsh-agent'
18
+ import {
19
+ formatSessionReferenceMention,
20
+ parseSessionReferenceText,
21
+ type SessionReferenceCandidate,
22
+ type SessionReferenceInput,
23
+ } from '@deepseek-ai/dsh-session-reference'
24
+
25
+ /** Parsed submission text: readable text plus structured references. */
26
+ type ParsedSessionReferenceText = ReturnType<typeof parseSessionReferenceText>
27
+
28
+ /** One filesystem entry the @ menu can complete. */
29
+ export interface FileCandidate {
30
+ /** Workspace-relative path with forward slashes. */
31
+ path: string
32
+ /** Entry kind; directories insert with a trailing slash. */
33
+ kind: 'file' | 'directory'
34
+ }
35
+
36
+ /** One merged menu candidate (files and sessions, already ranked). */
37
+ export interface MentionCandidate {
38
+ /** Text inserted after the `@` (directories carry a trailing slash). */
39
+ label: string
40
+ /** Human-readable origin shown beside the label. */
41
+ description: string
42
+ /** Origin kind for icon/coloring decisions. */
43
+ kind: 'file' | 'directory' | 'session'
44
+ }
45
+
46
+ /** Prepared submission: readable content plus optional injected context. */
47
+ export interface PreparedMention {
48
+ /** Readable text with mention tokens normalized to `@label`. */
49
+ text: string
50
+ /** Structured source sessions in appearance order (empty when none). */
51
+ references: SessionReferenceInput[]
52
+ /** Aggregated snapshot for `agent.inject()`, undefined without references. */
53
+ additionalContext?: import('@deepseek-ai/dsh-session').UserMessage
54
+ }
55
+
56
+ /** Directories never entered and files never listed during the scan. */
57
+ const SKIP_DIRS = new Set(['.git', 'node_modules', 'lib', 'dist', 'out', '.omc', 'coverage'])
58
+ const MAX_FILES = 4000
59
+ const MAX_DEPTH = 12
60
+
61
+ /** Bounded async BFS scan of a workspace; unreadable entries are skipped. */
62
+ export async function scanWorkspaceFiles(root: string, signal?: AbortSignal): Promise<readonly FileCandidate[]> {
63
+ const found: FileCandidate[] = []
64
+ const pending: Array<{ absolute: string; relative: string; depth: number }> = [{ absolute: root, relative: '', depth: 0 }]
65
+ const aborted = (): boolean => signal?.aborted === true
66
+ while (pending.length > 0 && found.length < MAX_FILES && !aborted()) {
67
+ const current = pending.shift()
68
+ if (current === undefined) break
69
+ let entries
70
+ try {
71
+ entries = await readdir(current.absolute, { withFileTypes: true })
72
+ } catch {
73
+ continue
74
+ }
75
+ for (const entry of entries) {
76
+ if (found.length >= MAX_FILES || aborted()) return found
77
+ if (entry.name.startsWith('.')) continue
78
+ const relative = current.relative === '' ? entry.name : `${current.relative}/${entry.name}`
79
+ if (entry.isDirectory()) {
80
+ if (SKIP_DIRS.has(entry.name) || current.depth + 1 > MAX_DEPTH) continue
81
+ pending.push({ absolute: join(current.absolute, entry.name), relative, depth: current.depth + 1 })
82
+ } else if (entry.isFile()) {
83
+ found.push({ path: relative, kind: 'file' })
84
+ }
85
+ }
86
+ }
87
+ return found.sort((left, right) => left.path < right.path ? -1 : 1)
88
+ }
89
+
90
+ /** True when every query character appears in order in the haystack. */
91
+ function isSubsequence(query: string, haystack: string): boolean {
92
+ let at = 0
93
+ for (const char of haystack) {
94
+ if (char === query[at]) at += 1
95
+ if (at >= query.length) return true
96
+ }
97
+ return at >= query.length
98
+ }
99
+
100
+ /** Rank one file path against the typed query (community-TUI scoring shape). */
101
+ function scoreFile(path: string, query: string): number {
102
+ const name = path.slice(path.lastIndexOf('/') + 1)
103
+ if (query === '') return 0
104
+ if (name === query) return 1000
105
+ if (name.startsWith(query)) return 900
106
+ if (name.includes(query)) return 700
107
+ if (path.includes(query)) return 500
108
+ if (isSubsequence(query, name)) return 300
109
+ return 0
110
+ }
111
+
112
+ /** The mention API the input editor and the runner share. */
113
+ export interface MentionsApi {
114
+ /** Scanned workspace files, cached across one session. */
115
+ files(): Promise<readonly FileCandidate[]>
116
+ /** Ranked menu candidates for the typed `@` query. */
117
+ candidates(query: string, signal?: AbortSignal): Promise<readonly MentionCandidate[]>
118
+ /** Parse submission text into readable text plus structured references. */
119
+ parse(text: string): ParsedSessionReferenceText
120
+ /**
121
+ * Snapshot references and build the injected context. Throws the service's
122
+ * typed error on failure — the caller restores the draft and notifies.
123
+ */
124
+ prepare(parsed: ParsedSessionReferenceText, signal?: AbortSignal): Promise<PreparedMention>
125
+ /** Canonical mention token for a picked session candidate. */
126
+ sessionMention(candidate: SessionReferenceCandidate): string
127
+ }
128
+
129
+ /**
130
+ * Create the mention API for one agent's workspace. A missing
131
+ * session-reference service degrades to file mentions only (the scan still
132
+ * works); `prepare` then passes text through untouched.
133
+ * @param ctx - context carrying the optional `sessionReferenceResolver`.
134
+ * @param agent - the session owner; excluded from its own candidates.
135
+ * @param cwd - workspace root to scan.
136
+ */
137
+ export function createMentions(ctx: Context, agent: Agent, cwd: string): MentionsApi {
138
+ const resolver = ctx.get('sessionReferenceResolver')
139
+ let filesPromise: Promise<readonly FileCandidate[]> | undefined
140
+
141
+ return {
142
+ files(): Promise<readonly FileCandidate[]> {
143
+ filesPromise ??= scanWorkspaceFiles(cwd)
144
+ return filesPromise
145
+ },
146
+ async candidates(query: string, signal?: AbortSignal): Promise<readonly MentionCandidate[]> {
147
+ const needle = query.trim()
148
+ const [files, sessions] = await Promise.all([
149
+ this.files(),
150
+ resolver === undefined
151
+ ? Promise.resolve([])
152
+ : resolver.listCandidates(agent, needle, 10, signal).catch(() => []),
153
+ ])
154
+ const fileRows: MentionCandidate[] = files
155
+ .filter(candidate => scoreFile(candidate.path, needle) > 0)
156
+ .sort((left, right) => scoreFile(right.path, needle) - scoreFile(left.path, needle))
157
+ .slice(0, 20)
158
+ .map(candidate => ({
159
+ label: candidate.path,
160
+ description: candidate.kind === 'directory' ? 'Folder' : 'File',
161
+ kind: candidate.kind,
162
+ }))
163
+ const sessionRows: MentionCandidate[] = sessions.map(candidate => ({
164
+ label: formatSessionReferenceMention(candidate),
165
+ description: `Session · ${candidate.cwd ?? '(no cwd)'}`,
166
+ kind: 'session',
167
+ }))
168
+ return [...sessionRows, ...fileRows]
169
+ },
170
+ parse(text: string): ParsedSessionReferenceText {
171
+ return parseSessionReferenceText(text)
172
+ },
173
+ async prepare(parsed: ParsedSessionReferenceText, signal?: AbortSignal): Promise<PreparedMention> {
174
+ if (parsed.references.length === 0 || resolver === undefined) {
175
+ return { text: parsed.text, references: parsed.references }
176
+ }
177
+ const prepared = await resolver.prepare(
178
+ agent,
179
+ [{ type: 'text', text: parsed.text }],
180
+ parsed.references,
181
+ signal,
182
+ )
183
+ return {
184
+ text: prepared.content.filter(block => block.type === 'text').map(block => block.text).join(''),
185
+ references: parsed.references,
186
+ additionalContext: prepared.additionalContext,
187
+ }
188
+ },
189
+ sessionMention(candidate: SessionReferenceCandidate): string {
190
+ return formatSessionReferenceMention({ sessionId: candidate.sessionId, label: candidate.label })
191
+ },
192
+ }
193
+ }
package/src/models.ts ADDED
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Model directory for the `/model` panel: the in-process equivalent of the
3
+ * web host's model catalog (`buildModelCatalog`), reading the advisory
4
+ * `ctx.llm` registry directly. Catalog membership is advisory — a route
5
+ * serving a model it stopped advertising stays usable — so selection never
6
+ * fails on catalog absence alone.
7
+ *
8
+ * @module @deepseek-ai/dsh-tui/models
9
+ */
10
+
11
+ import type { Context } from '@deepseek-ai/cordis'
12
+ import type { LlmModelInfo } from '@deepseek-ai/dsh-llm'
13
+
14
+ /** One selectable row in the `/model` panel. */
15
+ export interface ModelRow {
16
+ /** Registered provider route. */
17
+ provider: string
18
+ /** Display name of the provider route. */
19
+ providerName: string
20
+ /** Provider-owned model id. */
21
+ model: string
22
+ /** Human-readable model name. */
23
+ modelName: string
24
+ }
25
+
26
+ /** The resolved directory: rows plus per-provider discovery failures. */
27
+ export interface ModelDirectory {
28
+ /** Advisory rows, provider-major in registry order. */
29
+ rows: readonly ModelRow[]
30
+ /** Provider ids whose model listing failed; those providers contribute no rows. */
31
+ failures: readonly string[]
32
+ }
33
+
34
+ /**
35
+ * Load the selectable model directory from the live `ctx.llm` registry.
36
+ * Providers are listed synchronously; each provider's models are discovered
37
+ * with a bounded parallel fan-out whose failures degrade to that provider
38
+ * contributing no rows (mirrors the web catalog's per-provider failures).
39
+ * @param ctx - context carrying the `llm` service.
40
+ * @returns the resolved directory; empty rows when `llm` is unavailable.
41
+ */
42
+ export async function loadModelDirectory(ctx: Context): Promise<ModelDirectory> {
43
+ const llm = ctx.get('llm')
44
+ if (llm === undefined) return { rows: [], failures: [] }
45
+ const providers = llm.listProviders()
46
+ const listed = await Promise.all(providers.map(async (provider) => {
47
+ try {
48
+ const models: readonly LlmModelInfo[] = await llm.listModels(provider.id)
49
+ return {
50
+ provider: provider.id,
51
+ providerName: provider.name,
52
+ models: models.map(model => ({
53
+ provider: provider.id,
54
+ providerName: provider.name,
55
+ model: model.id,
56
+ modelName: model.name,
57
+ })),
58
+ }
59
+ } catch {
60
+ return { provider: provider.id, providerName: provider.name, models: [] as ModelRow[], failed: true }
61
+ }
62
+ }))
63
+ const rows = listed.flatMap(entry => entry.models)
64
+ const failures = listed.filter(entry => 'failed' in entry && entry.failed === true).map(entry => entry.provider)
65
+ return { rows, failures }
66
+ }
@@ -0,0 +1,143 @@
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.
8
+ *
9
+ * Plan reviews (`exit_plan_mode`) arrive through the same service with an
10
+ * `intent: { kind: 'plan-review' }` — the renderer highlights the approve
11
+ * option; the answer encoding is identical either way.
12
+ *
13
+ * @module @deepseek-ai/dsh-code/questions
14
+ */
15
+
16
+ import type { Context } from '@deepseek-ai/cordis'
17
+ import {
18
+ UserQuestionError,
19
+ type AskUserQuestionAnswer,
20
+ type AskUserQuestionRequest,
21
+ } from '@deepseek-ai/dsh-user-questions'
22
+
23
+ /** One question request waiting on the human, with its settle channels. */
24
+ export interface PendingQuestion {
25
+ /** The request the renderer walks through question by question. */
26
+ request: AskUserQuestionRequest
27
+ /** Resolve the provider promise with the collected answers. */
28
+ resolve(answers: AskUserQuestionAnswer): void
29
+ /** Reject the provider promise as aborted (also used for Esc cancel). */
30
+ reject(error: Error): void
31
+ }
32
+
33
+ /** The pending-question snapshot the renderer subscribes to. */
34
+ export interface QuestionSnapshot {
35
+ /** The active request, or undefined when nothing is being asked. */
36
+ pending: PendingQuestion | undefined
37
+ }
38
+
39
+ /** Store the pending question lands in; the renderer reads, the provider writes. */
40
+ export interface QuestionStore {
41
+ /** Subscribe to pending-state changes; returns the unsubscribe function. */
42
+ subscribe(listener: () => void): () => void
43
+ /** Read the current snapshot (identity-stable between changes). */
44
+ getSnapshot(): QuestionSnapshot
45
+ /** Submit the collected answers for the active request and advance the queue. */
46
+ submit(pending: PendingQuestion, answers: AskUserQuestionAnswer): void
47
+ /** Cancel the active request (Esc) — rejects ASK_ABORTED and advances the queue. */
48
+ cancel(pending: PendingQuestion): void
49
+ }
50
+
51
+ const ABORT_ERROR = new UserQuestionError(
52
+ 'ask_user_question was interrupted before the user answered',
53
+ 'ASK_ABORTED',
54
+ )
55
+
56
+ /**
57
+ * Mount the single `ctx.userQuestions` UI provider over a FIFO queue.
58
+ * @param ctx - context carrying the `userQuestions` service (dsh-base).
59
+ * @returns the store the renderer subscribes to; a context without the
60
+ * service yields a permanently empty store.
61
+ */
62
+ export function mountQuestionProvider(ctx: Context): QuestionStore {
63
+ const service = ctx.get('userQuestions')
64
+ let snapshot: QuestionSnapshot = { pending: undefined }
65
+ let active: PendingQuestion | undefined
66
+ const queue: PendingQuestion[] = []
67
+ const listeners = new Set<() => void>()
68
+ const set = (next: QuestionSnapshot): void => {
69
+ snapshot = next
70
+ for (const listener of listeners) listener()
71
+ }
72
+
73
+ /** Settle the active request and show the next queued one, if any. */
74
+ const advance = (): void => {
75
+ const next = queue.shift()
76
+ active = next
77
+ set({ pending: next })
78
+ }
79
+
80
+ if (service !== undefined) {
81
+ service.registerProvider({
82
+ ask(request: AskUserQuestionRequest): Promise<AskUserQuestionAnswer> {
83
+ return new Promise((resolve, reject) => {
84
+ const pending: PendingQuestion = {
85
+ request,
86
+ resolve,
87
+ reject,
88
+ }
89
+ // Abort settles through the same channel as an Esc cancel: the
90
+ // owning tool/step died, so the answer must not linger.
91
+ const onAbort = (): void => {
92
+ if (active === pending) {
93
+ active = undefined
94
+ set({ pending: undefined })
95
+ advance()
96
+ } else {
97
+ const at = queue.indexOf(pending)
98
+ if (at >= 0) queue.splice(at, 1)
99
+ }
100
+ reject(ABORT_ERROR)
101
+ }
102
+ if (request.signal?.aborted === true) {
103
+ reject(ABORT_ERROR)
104
+ return
105
+ }
106
+ request.signal?.addEventListener('abort', onAbort, { once: true })
107
+ if (active === undefined) {
108
+ active = pending
109
+ set({ pending })
110
+ } else {
111
+ queue.push(pending)
112
+ }
113
+ })
114
+ },
115
+ })
116
+ }
117
+
118
+ return {
119
+ subscribe(listener: () => void): () => void {
120
+ listeners.add(listener)
121
+ return () => {
122
+ listeners.delete(listener)
123
+ }
124
+ },
125
+ getSnapshot(): QuestionSnapshot {
126
+ return snapshot
127
+ },
128
+ submit(pending: PendingQuestion, answers: AskUserQuestionAnswer): void {
129
+ if (active !== pending) return
130
+ active = undefined
131
+ set({ pending: undefined })
132
+ pending.resolve(answers)
133
+ advance()
134
+ },
135
+ cancel(pending: PendingQuestion): void {
136
+ if (active !== pending) return
137
+ active = undefined
138
+ set({ pending: undefined })
139
+ pending.reject(ABORT_ERROR)
140
+ advance()
141
+ },
142
+ }
143
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Terminal animation frame tables derived from the web design language:
3
+ * the StateDot "ongoing" pixel chase (3×3 ring, 125ms flat-hold brightness
4
+ * steps, 1s cycle) becomes the single-cell stepped pulse below, and the
5
+ * streaming caret blink is the Claude-Code convention. Pure functions only —
6
+ * the Ink layer owns timers and colors.
7
+ *
8
+ * @module @deepseek-ai/dsh-code/render/animations
9
+ */
10
+
11
+ /** Single-cell stepped pulse: flat holds mirroring the web's 125ms keyframes. */
12
+ export const PULSE_FRAMES = ['█', '█', '▆', '▃', '▁', '▃', '▆', '█'] as const
13
+
14
+ /** Pulse frame for a monotonic tick. */
15
+ export function pulseFrame(tick: number): string {
16
+ return PULSE_FRAMES[tick % PULSE_FRAMES.length] ?? PULSE_FRAMES[0]
17
+ }
18
+
19
+ /** Caret visibility: half the ticks on, half off (530ms blink). */
20
+ export function caretVisible(tick: number): boolean {
21
+ return tick % 2 === 0
22
+ }
@@ -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
+ }