dsh-code 0.3.0 → 0.5.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.
- package/README.md +201 -55
- package/README.zh.md +204 -61
- package/bin/deepseek.mjs +70 -0
- package/cordis.patch.yml +62 -6
- package/lib/index.mjs +4073 -1802
- package/lib/startup.mjs +34 -17
- package/lib/types/app.d.ts +23 -22
- package/lib/types/commands.d.ts +2 -0
- package/lib/types/index.d.ts +5 -5
- package/lib/types/internals.d.ts +2 -0
- package/lib/types/kernel-panels.d.ts +23 -0
- package/lib/types/plugin-inventory.d.ts +11 -0
- package/lib/types/presets.d.ts +32 -0
- package/lib/types/render/export.d.ts +15 -0
- package/lib/types/render/inspector.d.ts +34 -0
- package/lib/types/render/lines.d.ts +31 -0
- package/lib/types/render/projection.d.ts +95 -3
- package/lib/types/render/status.d.ts +19 -0
- package/lib/types/render/text.d.ts +27 -0
- package/lib/types/render/tool-detail.d.ts +92 -0
- package/lib/types/session-directory.d.ts +54 -0
- package/lib/types/session-switch.d.ts +17 -0
- package/lib/types/skills.d.ts +2 -0
- package/lib/types/startup.d.ts +11 -1
- package/lib/types/store.d.ts +2 -0
- package/package.json +16 -1
- package/src/app.ts +1367 -277
- package/src/commands.ts +15 -1
- package/src/index.ts +373 -128
- package/src/internals.ts +5 -0
- package/src/kernel-panels.ts +254 -0
- package/src/plugin-inventory.ts +47 -0
- package/src/presets.ts +64 -0
- package/src/render/export.ts +81 -0
- package/src/render/inspector.ts +88 -0
- package/src/render/lines.ts +207 -0
- package/src/render/markdown.ts +15 -1
- package/src/render/projection.ts +279 -16
- package/src/render/status.ts +51 -1
- package/src/render/text.ts +107 -0
- package/src/render/tool-detail.ts +197 -0
- package/src/session-directory.ts +102 -0
- package/src/session-switch.ts +58 -0
- package/src/skills.ts +20 -7
- package/src/startup.ts +38 -20
- package/src/store.ts +8 -0
package/src/render/status.ts
CHANGED
|
@@ -36,6 +36,18 @@ export function formatDuration(ms: number): string {
|
|
|
36
36
|
return `${Math.floor(whole / 60)}m${whole % 60}s`
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
/**
|
|
40
|
+
* Compact decode rate: one decimal under a hundred, whole below a thousand,
|
|
41
|
+
* then thousands (15.3 / 124 / 1.2K).
|
|
42
|
+
* @param n - tokens per second.
|
|
43
|
+
* @returns display string.
|
|
44
|
+
*/
|
|
45
|
+
export function formatRate(n: number): string {
|
|
46
|
+
if (n < 100) return String(Math.round(n * 10) / 10)
|
|
47
|
+
if (n < 1_000) return String(Math.round(n))
|
|
48
|
+
return `${Math.round(n / 100) / 10}K`
|
|
49
|
+
}
|
|
50
|
+
|
|
39
51
|
/**
|
|
40
52
|
* Cache-hit share of billed prompt-side input.
|
|
41
53
|
* @param usage - cumulative token totals.
|
|
@@ -51,12 +63,20 @@ export function cacheHitPercent(usage: TranscriptStats['usage']): number | null
|
|
|
51
63
|
export interface StatusFacts {
|
|
52
64
|
/** `provider/model` selection serving this session. */
|
|
53
65
|
model: string
|
|
66
|
+
/** Agent preset composing this session. */
|
|
67
|
+
mode?: string
|
|
54
68
|
/** Working-directory basename the session serves. */
|
|
55
69
|
cwd: string
|
|
56
70
|
/** Git branch name, empty outside a repository or on a detached HEAD file. */
|
|
57
71
|
branch: string
|
|
58
72
|
/** Short session identifier (last dash-separated segment or tail). */
|
|
59
73
|
sessionId: string
|
|
74
|
+
/** Latest session title (folded from `session/title`); shown in place of the id. */
|
|
75
|
+
title: string
|
|
76
|
+
/** Sandbox-mode override (folded from `sandbox/mode`), empty when never switched. */
|
|
77
|
+
sandbox: string
|
|
78
|
+
/** Live goal summary (folded from `goal/change`), undefined when none. */
|
|
79
|
+
goal: { phase: string; rounds: number; max: number } | undefined
|
|
60
80
|
/** Whether plan mode is active (folded from `plan/mode`). */
|
|
61
81
|
plan: boolean
|
|
62
82
|
/** Active permission preset (folded from `permission/preset`), empty when unknown. */
|
|
@@ -78,22 +98,52 @@ export function buildStatusGroups(facts: StatusFacts, stats: TranscriptStats): s
|
|
|
78
98
|
facts.plan ? '⧉ plan' : undefined,
|
|
79
99
|
].filter(part => part !== undefined && part !== '')
|
|
80
100
|
if (identity.length > 0) groups.push(identity.join(' · '))
|
|
101
|
+
if (facts.mode !== undefined && facts.mode !== '') groups.push(`mode ${facts.mode}`)
|
|
81
102
|
if (stats.turns > 0 || stats.steps > 0) {
|
|
82
103
|
groups.push(`T${stats.turns} · S${stats.steps}`)
|
|
83
104
|
const durations: string[] = []
|
|
84
105
|
if (stats.llmMs > 0) durations.push(`llm ${formatDuration(stats.llmMs)}`)
|
|
106
|
+
// Decode latency figures (the web StatsLine's TTFT and throughput):
|
|
107
|
+
// average first-token wait and tokens per second over timed steps.
|
|
108
|
+
if (stats.ttftSteps > 0) durations.push(`ttft ${formatDuration(stats.ttftMs / stats.ttftSteps)}`)
|
|
109
|
+
if (stats.decodeMs > 0 && stats.decodeTokens > 0) {
|
|
110
|
+
durations.push(`${formatRate(stats.decodeTokens / (stats.decodeMs / 1_000))} tok/s`)
|
|
111
|
+
}
|
|
85
112
|
if (stats.toolMs > 0) durations.push(`tool ${formatDuration(stats.toolMs)}`)
|
|
86
113
|
if (durations.length > 0) groups.push(durations.join(' · '))
|
|
87
114
|
}
|
|
88
115
|
const cacheHit = cacheHitPercent(stats.usage)
|
|
89
116
|
if (stats.usage.inputTokens > 0 || stats.usage.outputTokens > 0) {
|
|
90
117
|
if (cacheHit !== null) groups.push(`cache ${cacheHit}%`)
|
|
118
|
+
// Context occupancy (the web StatsLine's meter): the most recent
|
|
119
|
+
// reported prompt size against the advertised route capacity.
|
|
120
|
+
if (stats.contextWindow > 0 && stats.lastPromptTokens > 0) {
|
|
121
|
+
groups.push(`ctx ${Math.min(999, Math.round(stats.lastPromptTokens / stats.contextWindow * 100))}%`)
|
|
122
|
+
}
|
|
91
123
|
groups.push(`↑${formatTokens(stats.usage.inputTokens)} ↓${formatTokens(stats.usage.outputTokens)}`)
|
|
92
124
|
}
|
|
93
|
-
|
|
125
|
+
// The session title replaces the bare short id whenever one has landed
|
|
126
|
+
// (user rename or provider generation), bounded so a long title cannot
|
|
127
|
+
// crowd out the rest of the line.
|
|
128
|
+
const label = facts.title !== undefined && facts.title !== ''
|
|
129
|
+
? (facts.title.length > 48 ? `${facts.title.slice(0, 47)}…` : facts.title)
|
|
130
|
+
: facts.sessionId
|
|
131
|
+
if (label !== '') groups.push(label)
|
|
94
132
|
// The permission preset trails the line: switching it changes only the
|
|
95
133
|
// tail, so the left-aligned bar never shifts its other groups. Plain text,
|
|
96
134
|
// the Claude-Code permission-mode display (no glyphs).
|
|
97
135
|
if (facts.permission !== undefined && facts.permission !== '') groups.push(facts.permission)
|
|
136
|
+
// The sandbox override stays implicit when it merely echoes the preset —
|
|
137
|
+
// the badge exists to surface a divergence, not to duplicate the label.
|
|
138
|
+
const sandbox = facts.sandbox ?? ''
|
|
139
|
+
if (sandbox !== '' && sandbox.toLowerCase() !== facts.permission.toLowerCase()) {
|
|
140
|
+
groups.push(`sandbox ${sandbox}`)
|
|
141
|
+
}
|
|
142
|
+
// Goal badge: round progress while active, the phase otherwise.
|
|
143
|
+
if (facts.goal !== undefined) {
|
|
144
|
+
groups.push(facts.goal.phase === 'active'
|
|
145
|
+
? `◎ r${facts.goal.rounds}/${facts.goal.max}`
|
|
146
|
+
: `◎ ${facts.goal.phase}`)
|
|
147
|
+
}
|
|
98
148
|
return groups
|
|
99
149
|
}
|
package/src/render/text.ts
CHANGED
|
@@ -22,3 +22,110 @@ const CONTROL_ESCAPE = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/gu
|
|
|
22
22
|
export function displayText(text: string): string {
|
|
23
23
|
return text.replace(CONTROL_ESCAPE, char => `\\x${char.charCodeAt(0).toString(16).padStart(2, '0')}`)
|
|
24
24
|
}
|
|
25
|
+
|
|
26
|
+
/** Collapse external text to one terminal-safe logical row. */
|
|
27
|
+
export function singleLineText(text: string): string {
|
|
28
|
+
return displayText(text).replace(/\r?\n/gu, ' ↵ ').replace(/\t/gu, ' ')
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Terminal-cell width matching the TUI's existing CJK-aware wrapping rule. */
|
|
32
|
+
function cellWidth(text: string): number {
|
|
33
|
+
let columns = 0
|
|
34
|
+
for (const char of text) {
|
|
35
|
+
columns += (char.codePointAt(0) ?? 0) > 0x2e7f ? 2 : 1
|
|
36
|
+
}
|
|
37
|
+
return columns
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Truncate one display-safe row without ever exceeding its physical-column
|
|
42
|
+
* budget. The ellipsis is included inside the budget, matching Codex's popup
|
|
43
|
+
* truncation contract; the previous app-local helper appended it after the
|
|
44
|
+
* row was already full and could force an extra terminal wrap.
|
|
45
|
+
*/
|
|
46
|
+
export function truncateColumns(text: string, columns: number): string {
|
|
47
|
+
const limit = Math.max(0, Math.floor(columns))
|
|
48
|
+
if (limit === 0) return ''
|
|
49
|
+
if (cellWidth(text) <= limit) return text
|
|
50
|
+
|
|
51
|
+
const contentLimit = limit - 1
|
|
52
|
+
let used = 0
|
|
53
|
+
let result = ''
|
|
54
|
+
for (const char of text) {
|
|
55
|
+
const width = cellWidth(char)
|
|
56
|
+
if (used + width > contentLimit) break
|
|
57
|
+
result += char
|
|
58
|
+
used += width
|
|
59
|
+
}
|
|
60
|
+
return `${result}…`
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** A display-safe suffix bounded by terminal rows and columns. */
|
|
64
|
+
export interface DisplayTail {
|
|
65
|
+
/** Sanitized suffix suitable for direct terminal rendering. */
|
|
66
|
+
text: string
|
|
67
|
+
/** Whether content before the returned suffix was omitted. */
|
|
68
|
+
truncated: boolean
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Read one Unicode character immediately before `end`. */
|
|
72
|
+
function previousCharacter(text: string, end: number): { char: string; start: number } {
|
|
73
|
+
const last = text.charCodeAt(end - 1)
|
|
74
|
+
if (last >= 0xdc00 && last <= 0xdfff && end >= 2) {
|
|
75
|
+
const first = text.charCodeAt(end - 2)
|
|
76
|
+
if (first >= 0xd800 && first <= 0xdbff) {
|
|
77
|
+
return { char: text.slice(end - 2, end), start: end - 2 }
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return { char: text.slice(end - 1, end), start: end - 1 }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Keep only the newest display-safe text that fits a terminal rectangle.
|
|
85
|
+
* The scan walks backward and stops as soon as the suffix is full, so a long
|
|
86
|
+
* reasoning stream does not rescan its entire accumulated prefix per chunk.
|
|
87
|
+
* Explicit newlines and terminal wrapping both consume rows.
|
|
88
|
+
* @param text - raw externally sourced text.
|
|
89
|
+
* @param columns - available terminal columns.
|
|
90
|
+
* @param rows - available terminal rows.
|
|
91
|
+
* @returns a sanitized bounded suffix and whether an earlier prefix was cut.
|
|
92
|
+
*/
|
|
93
|
+
export function displayTail(text: string, columns: number, rows: number): DisplayTail {
|
|
94
|
+
const columnLimit = Math.max(1, Math.floor(columns))
|
|
95
|
+
const rowLimit = Math.max(1, Math.floor(rows))
|
|
96
|
+
const reversed: string[] = []
|
|
97
|
+
let row = 1
|
|
98
|
+
let used = 0
|
|
99
|
+
let end = text.length
|
|
100
|
+
|
|
101
|
+
while (end > 0) {
|
|
102
|
+
const previous = previousCharacter(text, end)
|
|
103
|
+
if (previous.char === '\n') {
|
|
104
|
+
if (row >= rowLimit) break
|
|
105
|
+
reversed.push('\n')
|
|
106
|
+
row += 1
|
|
107
|
+
used = 0
|
|
108
|
+
end = previous.start
|
|
109
|
+
continue
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const safe = displayText(previous.char)
|
|
113
|
+
const width = cellWidth(safe)
|
|
114
|
+
if (used > 0 && used + width > columnLimit) {
|
|
115
|
+
if (row >= rowLimit) break
|
|
116
|
+
// Materialize the soft wrap. Ink otherwise reflows at word boundaries
|
|
117
|
+
// and can turn a cell-counted two-row suffix into three rendered rows.
|
|
118
|
+
reversed.push('\n')
|
|
119
|
+
row += 1
|
|
120
|
+
used = 0
|
|
121
|
+
}
|
|
122
|
+
const extraRows = Math.floor(Math.max(0, width - 1) / columnLimit)
|
|
123
|
+
if (row + extraRows > rowLimit) break
|
|
124
|
+
row += extraRows
|
|
125
|
+
reversed.push(safe)
|
|
126
|
+
used = extraRows === 0 ? used + width : width - extraRows * columnLimit
|
|
127
|
+
end = previous.start
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return { text: reversed.reverse().join(''), truncated: end > 0 }
|
|
131
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Expansion payloads for tool cards (the Ctrl+O verbose transcript): the
|
|
3
|
+
* TUI-side consumption of the harness presentation contract. Mutation and
|
|
4
|
+
* read tools persist a structured `tool/result.meta` (`diffs`, read
|
|
5
|
+
* windows, web sources) exactly so a capable UI can replay richer cards than
|
|
6
|
+
* the model-facing text; this module narrows that opaque JSON defensively —
|
|
7
|
+
* mirroring the upstream validators — and pre-formats bounded, render-ready
|
|
8
|
+
* rows. Malformed or absent metadata always degrades to the bounded raw
|
|
9
|
+
* result text, never throws during replay.
|
|
10
|
+
*
|
|
11
|
+
* @module @deepseek-ai/dsh-code/render/tool-detail
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** Budgets keeping one expanded card bounded on a terminal. */
|
|
15
|
+
const MAX_DIFF_LINES = 200
|
|
16
|
+
const MAX_READ_LINES = 120
|
|
17
|
+
const MAX_SOURCES = 10
|
|
18
|
+
const MAX_RAW_CHARS = 6000
|
|
19
|
+
const MAX_LINE_COLUMNS = 240
|
|
20
|
+
|
|
21
|
+
/** One rendered diff row: removed, added, or shared context. */
|
|
22
|
+
export interface DiffLine {
|
|
23
|
+
/** '-' removed, '+' added, ' ' context. */
|
|
24
|
+
mark: '-' | '+' | ' '
|
|
25
|
+
/** The line text, truncated to the column budget. */
|
|
26
|
+
text: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** One file's bounded inline diff. */
|
|
30
|
+
export interface ToolDiff {
|
|
31
|
+
/** File path the change belongs to. */
|
|
32
|
+
path: string
|
|
33
|
+
/** Rendered rows in order; '-' block before the '+' block. */
|
|
34
|
+
lines: readonly DiffLine[]
|
|
35
|
+
/** True when the line budget cut the hunk. */
|
|
36
|
+
truncated: boolean
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** One numbered line of a read window. */
|
|
40
|
+
export interface ToolReadLine {
|
|
41
|
+
/** 1-based file line number. */
|
|
42
|
+
number: number
|
|
43
|
+
/** The line text, truncated to the column budget. */
|
|
44
|
+
text: string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** One web-search source row. */
|
|
48
|
+
export interface ToolWebSource {
|
|
49
|
+
/** Source URL. */
|
|
50
|
+
url: string
|
|
51
|
+
/** Source title, when the provider returned one. */
|
|
52
|
+
title: string | undefined
|
|
53
|
+
/** Short excerpt, truncated to the column budget. */
|
|
54
|
+
snippet: string
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The expansion payload a verbose tool card renders; a discriminated union. */
|
|
58
|
+
export type ToolDetail =
|
|
59
|
+
| { kind: 'diff'; diffs: readonly ToolDiff[] }
|
|
60
|
+
| { kind: 'read'; path: string; offset: number; lines: readonly ToolReadLine[]; totalLines: number; truncated: boolean }
|
|
61
|
+
| { kind: 'web-search'; sources: readonly ToolWebSource[]; truncated: boolean }
|
|
62
|
+
| { kind: 'web-fetch'; url: string; statusCode: number }
|
|
63
|
+
| { kind: 'raw'; text: string; truncated: boolean }
|
|
64
|
+
|
|
65
|
+
/** Truncate one line to the visible-column budget with an ellipsis marker. */
|
|
66
|
+
function clipLine(text: string): string {
|
|
67
|
+
return text.length > MAX_LINE_COLUMNS ? `${text.slice(0, MAX_LINE_COLUMNS - 1)}…` : text
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Split text into lines, dropping the trailing empty element of a final newline. */
|
|
71
|
+
function toLines(text: string): string[] {
|
|
72
|
+
const split = text.split('\n')
|
|
73
|
+
return split.length > 0 && split[split.length - 1] === '' ? split.slice(0, -1) : split
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Render one change as removed-then-added rows, hunked by common prefix and
|
|
78
|
+
* suffix. A null before-image (file create) renders as pure additions. The
|
|
79
|
+
* budget caps emitted rows and reports the cut, so a whole-file overwrite
|
|
80
|
+
* never floods the transcript.
|
|
81
|
+
* @param oldText - prior content, or null for a create.
|
|
82
|
+
* @param newText - content after the change.
|
|
83
|
+
* @param budget - maximum rows to emit.
|
|
84
|
+
* @returns the bounded rows and whether they were cut.
|
|
85
|
+
*/
|
|
86
|
+
export function diffRows(oldText: string | null, newText: string, budget: number): { lines: readonly DiffLine[]; truncated: boolean } {
|
|
87
|
+
const oldLines = oldText === null ? [] : toLines(oldText)
|
|
88
|
+
const newLines = toLines(newText)
|
|
89
|
+
let prefix = 0
|
|
90
|
+
while (prefix < oldLines.length && prefix < newLines.length && oldLines[prefix] === newLines[prefix]) prefix += 1
|
|
91
|
+
let suffix = 0
|
|
92
|
+
while (
|
|
93
|
+
suffix < oldLines.length - prefix && suffix < newLines.length - prefix
|
|
94
|
+
&& oldLines[oldLines.length - 1 - suffix] === newLines[newLines.length - 1 - suffix]
|
|
95
|
+
) suffix += 1
|
|
96
|
+
const removed = oldLines.slice(prefix, oldLines.length - suffix)
|
|
97
|
+
const added = newLines.slice(prefix, newLines.length - suffix)
|
|
98
|
+
const rows: DiffLine[] = [
|
|
99
|
+
...removed.map((text): DiffLine => ({ mark: '-', text: clipLine(text) })),
|
|
100
|
+
...added.map((text): DiffLine => ({ mark: '+', text: clipLine(text) })),
|
|
101
|
+
]
|
|
102
|
+
if (rows.length <= budget) return { lines: rows, truncated: false }
|
|
103
|
+
return { lines: rows.slice(0, budget), truncated: true }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Whether `value` is a valid upstream FileDiff (defensive narrowing). */
|
|
107
|
+
function isFileDiff(value: unknown): value is { path: string; oldText: string | null; newText: string } {
|
|
108
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
|
|
109
|
+
const { path, oldText, newText } = value as Record<string, unknown>
|
|
110
|
+
return typeof path === 'string' && (oldText === null || typeof oldText === 'string') && typeof newText === 'string'
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Whether `value` is a valid read-window line (defensive narrowing). */
|
|
114
|
+
function isReadLine(value: unknown): value is { number: number; text: string } {
|
|
115
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
|
|
116
|
+
const { number, text } = value as Record<string, unknown>
|
|
117
|
+
return typeof number === 'number' && Number.isInteger(number) && number >= 1 && typeof text === 'string'
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Whether `value` is a valid web source (defensive narrowing). */
|
|
121
|
+
function isWebSource(value: unknown): value is { url: string; title?: unknown; snippet?: unknown } {
|
|
122
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
|
|
123
|
+
const { url, title, snippet } = value as Record<string, unknown>
|
|
124
|
+
return typeof url === 'string'
|
|
125
|
+
&& (title === undefined || typeof title === 'string')
|
|
126
|
+
&& (snippet === undefined || typeof snippet === 'string')
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Narrow the opaque `tool/result.meta` into one bounded expansion payload,
|
|
131
|
+
* mirroring the upstream presenters' degradation ladder: diffs (write/edit),
|
|
132
|
+
* read windows (read), sources (web_search), fetch summaries (web_fetch), and
|
|
133
|
+
* the bounded raw result text as the universal fallback.
|
|
134
|
+
* @param meta - the persisted presentation metadata, when the tool attached one.
|
|
135
|
+
* @param rawText - the joined text blocks of the result message.
|
|
136
|
+
* @returns the expansion payload, or undefined when nothing renderable exists.
|
|
137
|
+
*/
|
|
138
|
+
export function toolResultDetail(meta: unknown, rawText: string): ToolDetail | undefined {
|
|
139
|
+
if (typeof meta === 'object' && meta !== null && !Array.isArray(meta)) {
|
|
140
|
+
const record = meta as Record<string, unknown>
|
|
141
|
+
|
|
142
|
+
const diffs = record['diffs']
|
|
143
|
+
if (Array.isArray(diffs) && diffs.length > 0 && diffs.every(isFileDiff)) {
|
|
144
|
+
const budget = Math.max(8, Math.floor(MAX_DIFF_LINES / diffs.length))
|
|
145
|
+
return {
|
|
146
|
+
kind: 'diff',
|
|
147
|
+
diffs: diffs.map(diff => ({
|
|
148
|
+
path: diff.path,
|
|
149
|
+
...diffRows(diff.oldText, diff.newText, budget),
|
|
150
|
+
})),
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const { path, offset, lines, totalLines } = record
|
|
155
|
+
if (typeof path === 'string' && Number.isInteger(offset) && (offset as number) >= 1
|
|
156
|
+
&& Number.isInteger(totalLines) && (totalLines as number) >= 0
|
|
157
|
+
&& Array.isArray(lines) && lines.every(isReadLine)) {
|
|
158
|
+
const window = lines as { number: number; text: string }[]
|
|
159
|
+
const truncated = window.length > MAX_READ_LINES
|
|
160
|
+
return {
|
|
161
|
+
kind: 'read',
|
|
162
|
+
path,
|
|
163
|
+
offset: offset as number,
|
|
164
|
+
lines: (truncated ? window.slice(0, MAX_READ_LINES) : window)
|
|
165
|
+
.map(line => ({ number: line.number, text: clipLine(line.text) })),
|
|
166
|
+
totalLines: totalLines as number,
|
|
167
|
+
truncated,
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const sources = record['sources']
|
|
172
|
+
if (Array.isArray(sources) && sources.every(isWebSource)) {
|
|
173
|
+
const truncated = sources.length > MAX_SOURCES
|
|
174
|
+
return {
|
|
175
|
+
kind: 'web-search',
|
|
176
|
+
sources: (truncated ? sources.slice(0, MAX_SOURCES) : sources).map(source => ({
|
|
177
|
+
url: source.url,
|
|
178
|
+
title: typeof source.title === 'string' ? source.title : undefined,
|
|
179
|
+
snippet: typeof source.snippet === 'string' ? clipLine(source.snippet) : '',
|
|
180
|
+
})),
|
|
181
|
+
truncated,
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const { url, statusCode } = record
|
|
186
|
+
if (typeof url === 'string' && typeof statusCode === 'number') {
|
|
187
|
+
return { kind: 'web-fetch', url, statusCode: Math.trunc(statusCode) }
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
if (rawText === '') return undefined
|
|
191
|
+
const truncated = rawText.length > MAX_RAW_CHARS
|
|
192
|
+
return {
|
|
193
|
+
kind: 'raw',
|
|
194
|
+
text: truncated ? rawText.slice(0, MAX_RAW_CHARS) : rawText,
|
|
195
|
+
truncated,
|
|
196
|
+
}
|
|
197
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/** Lightweight session-directory projection for the /resume picker. */
|
|
2
|
+
|
|
3
|
+
import { basename, resolve } from 'node:path'
|
|
4
|
+
import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
|
|
5
|
+
|
|
6
|
+
export interface SessionRecord {
|
|
7
|
+
readonly header: SessionHeader
|
|
8
|
+
readonly live: boolean
|
|
9
|
+
readonly persisted: boolean
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface TitleObservationResult {
|
|
13
|
+
readonly sessionId: string
|
|
14
|
+
readonly status: 'fulfilled' | 'rejected'
|
|
15
|
+
readonly value?: { readonly title?: { readonly title?: string; readonly text?: string } }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface SessionLogSnapshot {
|
|
19
|
+
readonly session: SessionHeader
|
|
20
|
+
readonly events: SessionEvent[]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Structural upstream SessionQuery surface used by the TUI. */
|
|
24
|
+
export interface SessionQueryService {
|
|
25
|
+
listSessions(signal?: AbortSignal): Promise<SessionRecord[]>
|
|
26
|
+
readTitleSnapshots(ids: readonly string[], signal?: AbortSignal): Promise<TitleObservationResult[]>
|
|
27
|
+
readSession(id: string, signal?: AbortSignal): Promise<SessionLogSnapshot>
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type SessionScope = 'roots' | 'all'
|
|
31
|
+
export type CwdScope = 'all' | 'current'
|
|
32
|
+
export type SessionSort = 'newest' | 'oldest'
|
|
33
|
+
|
|
34
|
+
export interface SessionDirectoryOptions {
|
|
35
|
+
readonly sessions: SessionScope
|
|
36
|
+
readonly cwd: CwdScope
|
|
37
|
+
readonly sort: SessionSort
|
|
38
|
+
readonly currentCwd: string
|
|
39
|
+
readonly query: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface SessionRow {
|
|
43
|
+
readonly id: string
|
|
44
|
+
readonly createdAt: number
|
|
45
|
+
readonly cwd: string
|
|
46
|
+
readonly workspace: string
|
|
47
|
+
readonly parent?: string
|
|
48
|
+
readonly subagent: boolean
|
|
49
|
+
readonly resumable: boolean
|
|
50
|
+
readonly live: boolean
|
|
51
|
+
readonly persisted: boolean
|
|
52
|
+
readonly preset: string
|
|
53
|
+
readonly title?: string
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function samePath(left: string | undefined, right: string): boolean {
|
|
57
|
+
if (left === undefined) return false
|
|
58
|
+
return resolve(left).toLowerCase() === resolve(right).toLowerCase()
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Filter/sort header-only records. No session log is loaded here. */
|
|
62
|
+
export function projectSessionRows(records: readonly SessionRecord[], options: SessionDirectoryOptions): SessionRow[] {
|
|
63
|
+
const needle = options.query.trim().toLowerCase()
|
|
64
|
+
return records
|
|
65
|
+
.filter(record => options.sessions === 'all'
|
|
66
|
+
|| (record.header.parentSession === undefined && record.header.origin !== 'subagent'))
|
|
67
|
+
.filter(record => options.cwd === 'all' || samePath(record.header.cwd, options.currentCwd))
|
|
68
|
+
.map(record => {
|
|
69
|
+
const cwd = record.header.cwd ?? ''
|
|
70
|
+
const subagent = record.header.origin === 'subagent' || record.header.parentSession !== undefined
|
|
71
|
+
return {
|
|
72
|
+
id: record.header.id,
|
|
73
|
+
createdAt: record.header.createdAt,
|
|
74
|
+
cwd,
|
|
75
|
+
workspace: cwd === '' ? '(no workspace)' : basename(cwd),
|
|
76
|
+
parent: record.header.parentSession,
|
|
77
|
+
subagent,
|
|
78
|
+
resumable: !subagent,
|
|
79
|
+
live: record.live,
|
|
80
|
+
persisted: record.persisted,
|
|
81
|
+
preset: record.header.agentPreset ?? 'standard',
|
|
82
|
+
}
|
|
83
|
+
})
|
|
84
|
+
.filter(row => needle === '' || `${row.id} ${row.cwd} ${row.workspace} ${row.preset}`.toLowerCase().includes(needle))
|
|
85
|
+
.sort((left, right) => options.sort === 'newest'
|
|
86
|
+
? right.createdAt - left.createdAt
|
|
87
|
+
: left.createdAt - right.createdAt)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Merge page-local title observations without disturbing directory order. */
|
|
91
|
+
export function mergeSessionTitles(
|
|
92
|
+
rows: readonly SessionRow[],
|
|
93
|
+
observations: readonly TitleObservationResult[],
|
|
94
|
+
): SessionRow[] {
|
|
95
|
+
const titles = new Map<string, string>()
|
|
96
|
+
for (const observation of observations) {
|
|
97
|
+
if (observation.status !== 'fulfilled') continue
|
|
98
|
+
const title = observation.value?.title?.title ?? observation.value?.title?.text
|
|
99
|
+
if (title !== undefined && title.trim() !== '') titles.set(observation.sessionId, title)
|
|
100
|
+
}
|
|
101
|
+
return rows.map(row => titles.has(row.id) ? { ...row, title: titles.get(row.id) } : row)
|
|
102
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/** Latest-wins, idle-bound queue for safe Agent session changes. */
|
|
2
|
+
|
|
3
|
+
export interface IdleActivity {
|
|
4
|
+
readonly status: 'idle' | 'running'
|
|
5
|
+
whenIdle(): Promise<void>
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
interface Request<T> {
|
|
9
|
+
readonly activity: IdleActivity
|
|
10
|
+
readonly value: T
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class SessionSwitchQueue<T> {
|
|
14
|
+
private pending: Request<T> | undefined
|
|
15
|
+
private pumping = false
|
|
16
|
+
|
|
17
|
+
constructor(
|
|
18
|
+
private readonly execute: (value: T) => Promise<void>,
|
|
19
|
+
private readonly failed: (error: unknown) => void,
|
|
20
|
+
) {}
|
|
21
|
+
|
|
22
|
+
/** Queue a request; a later request replaces any request still waiting. */
|
|
23
|
+
request(activity: IdleActivity, value: T): 'queued' | 'started' {
|
|
24
|
+
this.pending = { activity, value }
|
|
25
|
+
const outcome = activity.status === 'running' || this.pumping ? 'queued' : 'started'
|
|
26
|
+
if (!this.pumping) void this.pump()
|
|
27
|
+
return outcome
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Cancel only work that has not begun activation. */
|
|
31
|
+
cancel(): boolean {
|
|
32
|
+
if (this.pending === undefined) return false
|
|
33
|
+
this.pending = undefined
|
|
34
|
+
return true
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
private async pump(): Promise<void> {
|
|
38
|
+
this.pumping = true
|
|
39
|
+
try {
|
|
40
|
+
while (this.pending !== undefined) {
|
|
41
|
+
const observed = this.pending
|
|
42
|
+
await observed.activity.whenIdle()
|
|
43
|
+
// Another request replaced this one while the turn was converging.
|
|
44
|
+
if (this.pending !== observed) continue
|
|
45
|
+
this.pending = undefined
|
|
46
|
+
try {
|
|
47
|
+
await this.execute(observed.value)
|
|
48
|
+
} catch (error: unknown) {
|
|
49
|
+
this.failed(error)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
} finally {
|
|
53
|
+
this.pumping = false
|
|
54
|
+
// A request may land between the loop condition and finally.
|
|
55
|
+
if (this.pending !== undefined) void this.pump()
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
package/src/skills.ts
CHANGED
|
@@ -28,6 +28,8 @@ export interface SkillRow {
|
|
|
28
28
|
export interface SkillsView {
|
|
29
29
|
/** Name-sorted user-invocable rows; empty until the first load lands. */
|
|
30
30
|
readonly rows: readonly SkillRow[]
|
|
31
|
+
/** Latest catalog-read failure; the help panel exposes it in place. */
|
|
32
|
+
readonly error?: string
|
|
31
33
|
/** Subscribe to catalog changes; returns the unsubscribe function. */
|
|
32
34
|
subscribe(listener: () => void): () => void
|
|
33
35
|
/** Retarget the agent whose workspace the catalog is read for. */
|
|
@@ -63,21 +65,29 @@ export function watchSkills(ctx: Context): SkillsWatch {
|
|
|
63
65
|
const skills = ctx.get('skills')
|
|
64
66
|
let agent: Agent | undefined
|
|
65
67
|
let rows: readonly SkillRow[] = []
|
|
68
|
+
let error: string | undefined
|
|
66
69
|
const listeners = new Set<() => void>()
|
|
67
70
|
|
|
68
71
|
const reload = (): void => {
|
|
69
|
-
|
|
70
|
-
skills
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
72
|
+
const currentAgent = agent
|
|
73
|
+
if (skills === undefined || currentAgent === undefined) return
|
|
74
|
+
Promise.resolve().then(() => skills.list({
|
|
75
|
+
cwd: currentAgent.session.header.cwd,
|
|
76
|
+
scope: currentAgent,
|
|
77
|
+
})).then((summaries: readonly SkillSummary[]) => {
|
|
74
78
|
const next = toRows(summaries)
|
|
75
|
-
|
|
79
|
+
const unchanged = next.length === rows.length && next.every((row, index) => row.name === rows[index]?.name)
|
|
76
80
|
rows = next
|
|
81
|
+
const recovered = error !== undefined
|
|
82
|
+
error = undefined
|
|
83
|
+
if (unchanged && !recovered) return
|
|
77
84
|
for (const listener of listeners) listener()
|
|
78
|
-
}
|
|
85
|
+
}).catch((cause: unknown) => {
|
|
79
86
|
// Discovery failure keeps the last good rows; the next skills/change
|
|
80
87
|
// notification is the retry surface (mirrors the web directory).
|
|
88
|
+
rows = [...rows]
|
|
89
|
+
error = cause instanceof Error ? cause.message : String(cause)
|
|
90
|
+
for (const listener of listeners) listener()
|
|
81
91
|
})
|
|
82
92
|
}
|
|
83
93
|
|
|
@@ -89,6 +99,9 @@ export function watchSkills(ctx: Context): SkillsWatch {
|
|
|
89
99
|
get rows(): readonly SkillRow[] {
|
|
90
100
|
return rows
|
|
91
101
|
},
|
|
102
|
+
get error(): string | undefined {
|
|
103
|
+
return error
|
|
104
|
+
},
|
|
92
105
|
subscribe(listener: () => void): () => void {
|
|
93
106
|
listeners.add(listener)
|
|
94
107
|
return () => {
|