dsh-code 0.7.0 → 0.9.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 (49) hide show
  1. package/README.en.md +30 -7
  2. package/README.md +30 -7
  3. package/lib/index.mjs +3791 -853
  4. package/lib/types/app.d.ts +90 -1
  5. package/lib/types/approval.d.ts +3 -1
  6. package/lib/types/history.d.ts +15 -4
  7. package/lib/types/index.d.ts +48 -0
  8. package/lib/types/kernel-panels.d.ts +65 -8
  9. package/lib/types/models.d.ts +15 -1
  10. package/lib/types/permissions.d.ts +37 -0
  11. package/lib/types/presets.d.ts +2 -0
  12. package/lib/types/provider-settings.d.ts +144 -0
  13. package/lib/types/questions.d.ts +2 -0
  14. package/lib/types/render/animations.d.ts +8 -6
  15. package/lib/types/render/lines.d.ts +6 -0
  16. package/lib/types/render/markdown.d.ts +3 -3
  17. package/lib/types/render/projection.d.ts +97 -3
  18. package/lib/types/render/status.d.ts +26 -36
  19. package/lib/types/render/text.d.ts +14 -7
  20. package/lib/types/render/tool-detail.d.ts +3 -1
  21. package/lib/types/render/tool-preview.d.ts +14 -1
  22. package/lib/types/session-directory.d.ts +61 -2
  23. package/lib/types/store.d.ts +13 -2
  24. package/lib/types/subagents.d.ts +60 -0
  25. package/lib/types/version.d.ts +5 -0
  26. package/package.json +1 -1
  27. package/src/app.ts +1200 -219
  28. package/src/approval.ts +161 -126
  29. package/src/history.ts +20 -5
  30. package/src/index.ts +577 -167
  31. package/src/kernel-panels.ts +354 -37
  32. package/src/models.ts +26 -0
  33. package/src/permissions.ts +85 -0
  34. package/src/presets.ts +12 -0
  35. package/src/provider-settings.ts +520 -0
  36. package/src/questions.ts +15 -5
  37. package/src/render/animations.ts +32 -18
  38. package/src/render/lines.ts +236 -218
  39. package/src/render/markdown.ts +302 -4
  40. package/src/render/projection.ts +670 -11
  41. package/src/render/status.ts +68 -162
  42. package/src/render/text.ts +28 -9
  43. package/src/render/tool-detail.ts +81 -40
  44. package/src/render/tool-preview.ts +77 -34
  45. package/src/session-directory.ts +171 -10
  46. package/src/skills.ts +8 -4
  47. package/src/store.ts +26 -8
  48. package/src/subagents.ts +165 -0
  49. package/src/version.ts +16 -0
@@ -3,14 +3,14 @@
3
3
  * block/inline parser producing styled line segments the Ink renderer maps
4
4
  * to colored text. No ANSI here — the app owns color mapping, tests own the
5
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).
6
+ * emphasis, inline/fenced code, flat lists, blockquotes, links, rules, GFM
7
+ * tables, and wrapped paragraphs. Unknown syntax degrades to plain text.
8
8
  *
9
9
  * @module @deepseek-ai/dsh-code/render/markdown
10
10
  */
11
11
 
12
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'
13
+ export type MdStyle = 'plain' | 'bold' | 'italic' | 'boldItalic' | 'code' | 'accent' | 'accentBold' | 'dim' | 'strike'
14
14
 
15
15
  /** One styled run of text. */
16
16
  export interface MdSegment {
@@ -162,6 +162,290 @@ const RULE = /^(?:---|\*\*\*|___)\s*$/u
162
162
  const QUOTE = /^>\s?(.*)$/u
163
163
  const UNORDERED = /^\s*[-*+]\s+(.*)$/u
164
164
  const ORDERED = /^\s*(\d+)[.)]\s+(.*)$/u
165
+ const TABLE_DELIMITER = /^(:)?-+(:)?$/u
166
+ const TABLE_COLUMN_GAP = 2
167
+ const TABLE_CELL_PADDING = 1
168
+ const TABLE_MIN_COLUMN_WIDTH = 3
169
+ const TABLE_MIN_ALIGNED_VALUE_WIDTH = 16
170
+ const MAX_TABLE_COLUMNS = 12
171
+
172
+ type TableAlignment = 'left' | 'center' | 'right'
173
+
174
+ interface ParsedTable {
175
+ headers: readonly string[]
176
+ alignments: readonly TableAlignment[]
177
+ rows: readonly (readonly string[])[]
178
+ nextIndex: number
179
+ }
180
+
181
+ /** Split one pipe row without treating escaped or inline-code pipes as cells. */
182
+ function splitTableRow(line: string): string[] | undefined {
183
+ const source = line.trim()
184
+ if (!source.includes('|')) return undefined
185
+ const cells: string[] = []
186
+ let current = ''
187
+ let inCode = false
188
+ let sawSeparator = false
189
+ for (let index = 0; index < source.length; index += 1) {
190
+ const char = source[index] ?? ''
191
+ if (char === '\\' && source[index + 1] === '|') {
192
+ current += '|'
193
+ index += 1
194
+ continue
195
+ }
196
+ if (char === '`') {
197
+ inCode = !inCode
198
+ current += char
199
+ continue
200
+ }
201
+ if (char === '|' && !inCode) {
202
+ cells.push(current.trim())
203
+ current = ''
204
+ sawSeparator = true
205
+ continue
206
+ }
207
+ current += char
208
+ }
209
+ if (!sawSeparator) return undefined
210
+ cells.push(current.trim())
211
+ if (source.startsWith('|') && cells[0] === '') cells.shift()
212
+ if (source.endsWith('|') && cells.at(-1) === '') cells.pop()
213
+ return cells.length === 0 ? undefined : cells
214
+ }
215
+
216
+ /** Parse the alignment marker carried by one GFM delimiter cell. */
217
+ function tableAlignment(cell: string): TableAlignment | undefined {
218
+ const match = TABLE_DELIMITER.exec(cell)
219
+ if (match === null) return undefined
220
+ if (match[1] !== undefined && match[2] !== undefined) return 'center'
221
+ if (match[2] !== undefined) return 'right'
222
+ return 'left'
223
+ }
224
+
225
+ /** Recognize one complete GFM pipe table at `startIndex`. */
226
+ function parseTable(source: readonly string[], startIndex: number): ParsedTable | undefined {
227
+ const headers = splitTableRow(source[startIndex] ?? '')
228
+ const delimiters = splitTableRow(source[startIndex + 1] ?? '')
229
+ if (headers === undefined || delimiters === undefined || headers.length !== delimiters.length) return undefined
230
+ if (headers.length === 0 || headers.length > MAX_TABLE_COLUMNS) return undefined
231
+ const alignments = delimiters.map(tableAlignment)
232
+ if (alignments.some(alignment => alignment === undefined)) return undefined
233
+
234
+ const rows: string[][] = []
235
+ let nextIndex = startIndex + 2
236
+ while (nextIndex < source.length) {
237
+ const cells = splitTableRow(source[nextIndex] ?? '')
238
+ if (cells === undefined) break
239
+ rows.push(Array.from({ length: headers.length }, (_, column) => cells[column] ?? ''))
240
+ nextIndex += 1
241
+ }
242
+ return {
243
+ headers,
244
+ alignments: alignments as TableAlignment[],
245
+ rows,
246
+ nextIndex,
247
+ }
248
+ }
249
+
250
+ /** Visible width of one styled cell line. */
251
+ function segmentsWidth(segments: readonly MdSegment[]): number {
252
+ return segments.reduce((total, segment) => total + visibleColumns(segment.text), 0)
253
+ }
254
+
255
+ /** Drop wrapping-only leading spaces while preserving styles. */
256
+ function trimLeadingSpaces(segments: readonly MdSegment[]): readonly MdSegment[] {
257
+ const trimmed = segments.map(segment => ({ ...segment }))
258
+ while (trimmed[0]?.text.startsWith(' ') === true) {
259
+ const first = trimmed[0]!
260
+ const text = first.text.replace(/^ +/u, '')
261
+ if (text === '') trimmed.shift()
262
+ else trimmed[0] = { ...first, text }
263
+ }
264
+ return trimmed
265
+ }
266
+
267
+ /** Hard-split an oversized soft-wrapped row while retaining inline styles. */
268
+ function hardWrapSegments(segments: readonly MdSegment[], width: number): readonly MdSegment[][] {
269
+ const out: MdSegment[][] = []
270
+ let current: MdSegment[] = []
271
+ let used = 0
272
+ const flush = (): void => {
273
+ out.push(current)
274
+ current = []
275
+ used = 0
276
+ }
277
+ for (const segment of segments) {
278
+ for (const char of segment.text) {
279
+ const cells = visibleColumns(char)
280
+ if (used > 0 && used + cells > width) flush()
281
+ const previous = current.at(-1)
282
+ if (previous?.style === segment.style) previous.text += char
283
+ else current.push({ text: char, style: segment.style })
284
+ used += cells
285
+ }
286
+ }
287
+ if (current.length > 0 || out.length === 0) flush()
288
+ return out
289
+ }
290
+
291
+ /** Wrap one table cell to its allocated content width. */
292
+ function wrapTableCell(runs: readonly InlineRun[], width: number): readonly MdSegment[][] {
293
+ const segments = runs.map(run => seg(run.text, run.style))
294
+ const soft = wrapSegments(segments, Math.max(1, width))
295
+ if (soft.length === 0) return [[]]
296
+ const wrapped: MdSegment[][] = []
297
+ for (const line of soft) {
298
+ const trimmed = trimLeadingSpaces(line)
299
+ if (segmentsWidth(trimmed) <= width) wrapped.push(trimmed.map(segment => ({ ...segment })))
300
+ else wrapped.push(...hardWrapSegments(trimmed, width))
301
+ }
302
+ return wrapped
303
+ }
304
+
305
+ /** Visible width after removing inline Markdown delimiters. */
306
+ function tableCellWidth(cell: string): number {
307
+ return segmentsWidth(parseInline(cell).map(run => seg(run.text, run.style)))
308
+ }
309
+
310
+ /** Allocate readable grid widths or request the vertical record fallback. */
311
+ function tableColumnWidths(table: ParsedTable, width: number): readonly number[] | undefined {
312
+ const columnCount = table.headers.length
313
+ const reserved = (columnCount * TABLE_CELL_PADDING) + ((columnCount - 1) * TABLE_COLUMN_GAP)
314
+ const available = width - reserved
315
+ if (available < columnCount * TABLE_MIN_COLUMN_WIDTH) return undefined
316
+ const widths = table.headers.map((header, column) => Math.max(
317
+ TABLE_MIN_COLUMN_WIDTH,
318
+ tableCellWidth(header),
319
+ ...table.rows.map(row => tableCellWidth(row[column] ?? '')),
320
+ ))
321
+ let overflow = widths.reduce((total, value) => total + value, 0) - available
322
+ while (overflow > 0) {
323
+ let widest = -1
324
+ for (let column = 0; column < widths.length; column += 1) {
325
+ if ((widths[column] ?? 0) <= TABLE_MIN_COLUMN_WIDTH) continue
326
+ if (widest < 0 || (widths[column] ?? 0) > (widths[widest] ?? 0)) widest = column
327
+ }
328
+ if (widest < 0) return undefined
329
+ widths[widest] = (widths[widest] ?? TABLE_MIN_COLUMN_WIDTH) - 1
330
+ overflow -= 1
331
+ }
332
+ return widths
333
+ }
334
+
335
+ /**
336
+ * Reject grids whose headers or body values become fragmented vertical strips.
337
+ * This mirrors Codex's readability fallback without importing its larger table
338
+ * classification machinery: systemic long-token breaks or a seven-line prose
339
+ * cell are clearer as vertical field records.
340
+ */
341
+ function tableGridIsReadable(table: ParsedTable, widths: readonly number[]): boolean {
342
+ const wrappedHeaders = table.headers.filter((header, column) => tableCellWidth(header) > (widths[column] ?? 0)).length
343
+ if (wrappedHeaders >= 2) return false
344
+ let affectedRows = 0
345
+ for (const row of table.rows) {
346
+ let affected = false
347
+ for (let column = 0; column < table.headers.length; column += 1) {
348
+ const width = widths[column] ?? TABLE_MIN_COLUMN_WIDTH
349
+ const runs = parseInline(row[column] ?? '')
350
+ const plain = runs.map(run => run.text).join('')
351
+ const fragmentedToken = plain.split(/\s+/u).some(token => visibleColumns(token) > width)
352
+ const wrappedHeight = wrapTableCell(runs, width).length
353
+ const catastrophicProse = plain.trim().split(/\s+/u).length >= 4 && width < 12 && wrappedHeight >= 7
354
+ if (fragmentedToken || catastrophicProse) {
355
+ affected = true
356
+ break
357
+ }
358
+ }
359
+ if (affected) affectedRows += 1
360
+ }
361
+ const threshold = table.rows.length <= 1 ? 1 : Math.max(2, Math.ceil(table.rows.length / 3))
362
+ return affectedRows < threshold
363
+ }
364
+
365
+ /** Apply one table-cell alignment to a wrapped content row. */
366
+ function alignedCell(
367
+ segments: readonly MdSegment[],
368
+ width: number,
369
+ alignment: TableAlignment,
370
+ ): { left: number; right: number } {
371
+ const remaining = Math.max(0, width - segmentsWidth(segments))
372
+ if (alignment === 'right') return { left: remaining, right: 0 }
373
+ if (alignment === 'center') return { left: Math.floor(remaining / 2), right: Math.ceil(remaining / 2) }
374
+ return { left: 0, right: remaining }
375
+ }
376
+
377
+ /** Render one logical grid row, including wrapped cell continuations. */
378
+ function renderTableGridRow(
379
+ cells: readonly string[],
380
+ widths: readonly number[],
381
+ alignments: readonly TableAlignment[],
382
+ header: boolean,
383
+ ): readonly MdLine[] {
384
+ const wrapped = cells.map((cell, column) => wrapTableCell(parseInline(cell), widths[column] ?? TABLE_MIN_COLUMN_WIDTH))
385
+ const height = Math.max(1, ...wrapped.map(lines => lines.length))
386
+ return Array.from({ length: height }, (_, rowIndex) => {
387
+ const segments: MdSegment[] = []
388
+ for (let column = 0; column < cells.length; column += 1) {
389
+ const line = wrapped[column]?.[rowIndex] ?? []
390
+ const styled = header
391
+ ? line.map(segment => ({ ...segment, style: 'accentBold' as const }))
392
+ : line
393
+ const alignment = alignedCell(styled, widths[column] ?? TABLE_MIN_COLUMN_WIDTH, alignments[column] ?? 'left')
394
+ segments.push(seg(' '.repeat(TABLE_CELL_PADDING + alignment.left)))
395
+ segments.push(...styled)
396
+ if (column + 1 < cells.length) segments.push(seg(' '.repeat(alignment.right + TABLE_COLUMN_GAP)))
397
+ }
398
+ return { segments: merge(segments) }
399
+ })
400
+ }
401
+
402
+ /** Render a table as Codex-style borderless rows with measured separators. */
403
+ function renderTableGrid(table: ParsedTable, widths: readonly number[]): readonly MdLine[] {
404
+ const separator = (char: string): MdLine => ({
405
+ segments: [seg(widths.map(width => char.repeat(width + TABLE_CELL_PADDING)).join(' '.repeat(TABLE_COLUMN_GAP)), 'dim')],
406
+ })
407
+ const lines: MdLine[] = [
408
+ ...renderTableGridRow(table.headers, widths, table.alignments, true),
409
+ separator('━'),
410
+ ]
411
+ for (let row = 0; row < table.rows.length; row += 1) {
412
+ lines.push(...renderTableGridRow(table.rows[row] ?? [], widths, table.alignments, false))
413
+ if (row + 1 < table.rows.length) lines.push(separator('─'))
414
+ }
415
+ return lines
416
+ }
417
+
418
+ /** Render an unreadably narrow grid as vertically scannable field records. */
419
+ function renderTableRecords(table: ParsedTable, width: number): readonly MdLine[] {
420
+ const labelWidth = Math.max(...table.headers.map(tableCellWidth))
421
+ const prefixWidth = TABLE_CELL_PADDING + labelWidth + TABLE_COLUMN_GAP
422
+ const aligned = prefixWidth + TABLE_MIN_ALIGNED_VALUE_WIDTH <= width
423
+ const lines: MdLine[] = []
424
+ for (let rowIndex = 0; rowIndex < table.rows.length; rowIndex += 1) {
425
+ const row = table.rows[rowIndex] ?? []
426
+ for (let column = 0; column < table.headers.length; column += 1) {
427
+ const label = table.headers[column] ?? ''
428
+ const value = row[column] ?? ''
429
+ const labelRuns = parseInline(label).map(run => seg(run.text, 'accentBold'))
430
+ if (aligned) {
431
+ const valueLines = wrapTableCell(parseInline(value), Math.max(1, width - prefixWidth))
432
+ for (let lineIndex = 0; lineIndex < valueLines.length; lineIndex += 1) {
433
+ const prefix = lineIndex === 0
434
+ ? [seg(' '), ...labelRuns, seg(' '.repeat(labelWidth - tableCellWidth(label) + TABLE_COLUMN_GAP))]
435
+ : [seg(' '.repeat(prefixWidth))]
436
+ lines.push({ segments: merge([...prefix, ...(valueLines[lineIndex] ?? [])]) })
437
+ }
438
+ } else {
439
+ lines.push({ segments: merge([seg(' '), ...labelRuns]) })
440
+ for (const valueLine of wrapTableCell(parseInline(value), Math.max(1, width - 2))) {
441
+ lines.push({ segments: merge([seg(' '), ...valueLine]) })
442
+ }
443
+ }
444
+ }
445
+ if (rowIndex + 1 < table.rows.length) lines.push({ segments: [seg('─'.repeat(width), 'dim')] })
446
+ }
447
+ return lines
448
+ }
165
449
 
166
450
  /** Render markdown text into styled lines of at most `width` columns. */
167
451
  export function renderMarkdown(text: string, width: number): readonly MdLine[] {
@@ -178,7 +462,10 @@ export function renderMarkdown(text: string, width: number): readonly MdLine[] {
178
462
  }
179
463
  separatorPending = false
180
464
  }
181
- const raw = text.replaceAll('\r', '')
465
+ // Tabs become two visible spaces: terminal tab stops are contextual and
466
+ // cannot participate in a deterministic column budget (the same rule the
467
+ // styled-row path applies in lines.ts).
468
+ const raw = text.replaceAll('\r', '').replaceAll('\t', ' ')
182
469
  const source = raw.split('\n')
183
470
  let index = 0
184
471
  while (index < source.length) {
@@ -193,6 +480,17 @@ export function renderMarkdown(text: string, width: number): readonly MdLine[] {
193
480
  }
194
481
  startBlock()
195
482
 
483
+ const table = parseTable(source, index - 1)
484
+ if (table !== undefined) {
485
+ const tableWidth = Math.max(10, Math.floor(width))
486
+ const columnWidths = tableColumnWidths(table, tableWidth)
487
+ lines.push(...(columnWidths === undefined || !tableGridIsReadable(table, columnWidths)
488
+ ? renderTableRecords(table, tableWidth)
489
+ : renderTableGrid(table, columnWidths)))
490
+ index = table.nextIndex
491
+ continue
492
+ }
493
+
196
494
  // Fenced code block: verbatim lines in code style, language label first.
197
495
  const fence = FENCE.exec(line)
198
496
  if (fence !== null) {