dsh-code 0.4.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.
@@ -0,0 +1,254 @@
1
+ /** Bounded, composer-safe panels for preset, session, and plugin kernel views. */
2
+
3
+ import { createElement, useEffect, useMemo, useRef, useState, type ReactElement } from 'react'
4
+ import { Box, Text, useInput, useStdout } from 'ink'
5
+ import type { PresetRow } from './presets.ts'
6
+ import type { PluginRow } from './plugin-inventory.ts'
7
+ import type { SessionDirectoryOptions, SessionRow } from './session-directory.ts'
8
+ import { panelViewport, revealRow } from './render/inspector.ts'
9
+ import { textLines } from './render/lines.ts'
10
+ import { singleLineText, truncateColumns } from './render/text.ts'
11
+ import { TUI_RGB } from './theme.ts'
12
+
13
+ function color(rgb: readonly [number, number, number]): string {
14
+ return `rgb(${rgb[0]}, ${rgb[1]}, ${rgb[2]})`
15
+ }
16
+
17
+ interface ListFrameProps {
18
+ readonly title: string
19
+ readonly rows: readonly { readonly key: string; readonly text: string; readonly disabled?: boolean }[]
20
+ readonly cursor: number
21
+ readonly loading: boolean
22
+ readonly error?: string
23
+ readonly query: string
24
+ readonly footer: string
25
+ }
26
+
27
+ function ListFrame(props: ListFrameProps): ReactElement {
28
+ const stdout = useStdout().stdout
29
+ const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
30
+ if (viewport.maxHeight === 0) return createElement(Box, { display: 'none' })
31
+ if (viewport.compact) {
32
+ return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(`${props.title} · esc close`, viewport.contentColumns))
33
+ }
34
+ const stateRows = props.loading
35
+ ? [{ key: 'loading', text: ' loading…' }]
36
+ : props.error !== undefined
37
+ ? [{ key: 'error', text: ` ${singleLineText(props.error)}` }]
38
+ : props.rows.length === 0
39
+ ? [{ key: 'empty', text: ' no matching entries' }]
40
+ : props.rows
41
+ const bodyRows = Math.max(1, viewport.bodyRows - 1)
42
+ const offset = revealRow(0, props.cursor, stateRows.length, bodyRows)
43
+ const visible = stateRows.slice(offset, offset + bodyRows)
44
+ return createElement(
45
+ Box,
46
+ { borderStyle: 'round', borderColor: color(TUI_RGB.dim), flexDirection: 'column', paddingX: 1 },
47
+ createElement(Text, { color: color(TUI_RGB.brandBright), wrap: 'truncate-end' }, truncateColumns(props.title, viewport.contentColumns)),
48
+ createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(`search: ${props.query === '' ? 'type to filter' : props.query}`, viewport.contentColumns)),
49
+ ...visible.map((row, index) => {
50
+ const absolute = offset + index
51
+ const selected = !props.loading && props.error === undefined && props.rows.length > 0 && absolute === props.cursor
52
+ return createElement(Text, {
53
+ key: row.key,
54
+ color: selected ? color(TUI_RGB.brandBright) : row.disabled ? color(TUI_RGB.dim) : undefined,
55
+ dimColor: row.disabled,
56
+ wrap: 'truncate-end',
57
+ }, truncateColumns(`${selected ? '› ' : ' '}${row.text}`, viewport.contentColumns))
58
+ }),
59
+ createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(props.footer, viewport.contentColumns)),
60
+ )
61
+ }
62
+
63
+ function editQuery(query: string, input: string, key: { backspace?: boolean; delete?: boolean }): string | undefined {
64
+ if (key.backspace || key.delete) return query.slice(0, -1)
65
+ if (input.length === 1 && input >= ' ' && input !== '\x7f') return query + input
66
+ return undefined
67
+ }
68
+
69
+ export function ModePanel({ current, load, select, close }: {
70
+ current: string
71
+ load(): Promise<readonly PresetRow[]>
72
+ select(id: string): void
73
+ close(): void
74
+ }): ReactElement {
75
+ const [rows, setRows] = useState<readonly PresetRow[]>([])
76
+ const [query, setQuery] = useState('')
77
+ const [cursor, setCursor] = useState(0)
78
+ const [loading, setLoading] = useState(true)
79
+ const [error, setError] = useState<string>()
80
+ const refresh = (): void => {
81
+ setLoading(true); setError(undefined)
82
+ Promise.resolve().then(load).then(value => { setRows(value); setLoading(false) }, reason => {
83
+ setError(reason instanceof Error ? reason.message : String(reason)); setLoading(false)
84
+ })
85
+ }
86
+ useEffect(refresh, [])
87
+ const visible = useMemo(() => rows.filter(row => `${row.id} ${row.name ?? ''} ${row.description ?? ''}`.toLowerCase().includes(query.toLowerCase())), [rows, query])
88
+ useEffect(() => setCursor(value => Math.min(value, Math.max(0, visible.length - 1))), [visible.length])
89
+ useInput((input, key) => {
90
+ if (key.escape || input === 'q') return close()
91
+ if (input === 'r' && query === '') return refresh()
92
+ if (key.upArrow) return setCursor(value => visible.length === 0 ? 0 : (value + visible.length - 1) % visible.length)
93
+ if (key.downArrow) return setCursor(value => visible.length === 0 ? 0 : (value + 1) % visible.length)
94
+ if (key.return && visible[cursor]?.broken === undefined) return select(visible[cursor]!.id)
95
+ const next = editQuery(query, input, key)
96
+ if (next !== undefined) { setQuery(next); setCursor(0) }
97
+ })
98
+ return createElement(ListFrame, {
99
+ title: `/mode · current ${current}`,
100
+ rows: visible.map(row => ({ key: row.id, disabled: row.broken !== undefined, text: `${row.id === current ? '●' : '○'} ${row.name ?? row.id} · ${row.description ?? row.trust}${row.broken === undefined ? '' : ` · broken: ${row.broken}`}` })),
101
+ cursor, loading, error, query, footer: '↑↓ choose · enter switch · r refresh · esc close',
102
+ })
103
+ }
104
+
105
+ export function PluginPanel({ load, close, initialQuery = '' }: { load(): readonly PluginRow[]; close(): void; initialQuery?: string }): ReactElement {
106
+ const [epoch, setEpoch] = useState(0)
107
+ const [query, setQuery] = useState(initialQuery)
108
+ const [cursor, setCursor] = useState(0)
109
+ const [expanded, setExpanded] = useState(false)
110
+ const rows = useMemo(() => load().filter(row => `${row.entryId} ${row.moduleName} ${row.phase ?? ''}`.toLowerCase().includes(query.toLowerCase())), [epoch, query])
111
+ useEffect(() => setCursor(value => Math.min(value, Math.max(0, rows.length - 1))), [rows.length])
112
+ useInput((input, key) => {
113
+ if (key.escape || input === 'q') return close()
114
+ if (input === 'r' && query === '') return setEpoch(value => value + 1)
115
+ if (key.upArrow) return setCursor(value => rows.length === 0 ? 0 : (value + rows.length - 1) % rows.length)
116
+ if (key.downArrow) return setCursor(value => rows.length === 0 ? 0 : (value + 1) % rows.length)
117
+ if (key.return) return setExpanded(value => !value)
118
+ const next = editQuery(query, input, key)
119
+ if (next !== undefined) { setQuery(next); setCursor(0) }
120
+ })
121
+ return createElement(ListFrame, {
122
+ title: '/plugin · loader inspector',
123
+ rows: rows.map((row, index) => ({
124
+ key: row.entryId,
125
+ disabled: !row.enabled,
126
+ text: `${row.enabled ? '●' : '○'} ${row.entryId} · ${row.phase ?? 'not mounted'}${expanded && index === cursor ? ` · ${row.moduleName}` : ''}`,
127
+ })), cursor, loading: false, query, footer: '↑↓ inspect · enter details · r refresh · esc close',
128
+ })
129
+ }
130
+
131
+ export function ResumePanel({ currentCwd, load, readTranscript, select, close }: {
132
+ currentCwd: string
133
+ load(options: SessionDirectoryOptions, signal?: AbortSignal): Promise<readonly SessionRow[]>
134
+ readTranscript(id: string, signal?: AbortSignal): Promise<string>
135
+ select(row: SessionRow): void
136
+ close(): void
137
+ }): ReactElement {
138
+ const [options, setOptions] = useState<SessionDirectoryOptions>({ sessions: 'roots', cwd: 'all', sort: 'newest', currentCwd, query: '' })
139
+ const [focus, setFocus] = useState(0)
140
+ const [density, setDensity] = useState<'comfortable' | 'dense'>('comfortable')
141
+ const [rows, setRows] = useState<readonly SessionRow[]>([])
142
+ const [cursor, setCursor] = useState(0)
143
+ const [loading, setLoading] = useState(true)
144
+ const [error, setError] = useState<string>()
145
+ const [expanded, setExpanded] = useState<string>()
146
+ const [transcript, setTranscript] = useState<{ id: string; text?: string; error?: string }>()
147
+ const transcriptLoad = useRef<AbortController>()
148
+ useEffect(() => () => transcriptLoad.current?.abort(), [])
149
+ useEffect(() => {
150
+ const controller = new AbortController()
151
+ setLoading(true); setError(undefined)
152
+ Promise.resolve().then(() => load(options, controller.signal)).then(value => {
153
+ if (!controller.signal.aborted) { setRows(value); setLoading(false) }
154
+ }, reason => {
155
+ if (!controller.signal.aborted) { setError(reason instanceof Error ? reason.message : String(reason)); setLoading(false) }
156
+ })
157
+ return () => controller.abort()
158
+ }, [options])
159
+ useEffect(() => setCursor(value => Math.min(value, Math.max(0, rows.length - 1))), [rows.length])
160
+ const cycle = (): void => {
161
+ if (focus === 3) {
162
+ setDensity(current => current === 'comfortable' ? 'dense' : 'comfortable')
163
+ return
164
+ }
165
+ setOptions(value => {
166
+ if (focus === 0) return { ...value, sessions: value.sessions === 'roots' ? 'all' : 'roots' }
167
+ if (focus === 1) return { ...value, cwd: value.cwd === 'all' ? 'current' : 'all' }
168
+ return { ...value, sort: value.sort === 'newest' ? 'oldest' : 'newest' }
169
+ })
170
+ }
171
+ useInput((input, key) => {
172
+ if (key.escape || input === 'q') return close()
173
+ if (key.tab) return setFocus(value => (value + (key.shift ? 3 : 1)) % 4)
174
+ if (key.leftArrow) return cycle()
175
+ if (key.rightArrow) return cycle()
176
+ if (key.upArrow) return setCursor(value => rows.length === 0 ? 0 : Math.max(0, value - 1))
177
+ if (key.downArrow) return setCursor(value => rows.length === 0 ? 0 : Math.min(rows.length - 1, value + 1))
178
+ if (key.pageUp) return setCursor(value => Math.max(0, value - 8))
179
+ if (key.pageDown) return setCursor(value => Math.min(rows.length - 1, value + 8))
180
+ if (input === 'g') return setCursor(0)
181
+ if (input === 'G') return setCursor(Math.max(0, rows.length - 1))
182
+ if (input === 'd') return setDensity(value => value === 'comfortable' ? 'dense' : 'comfortable')
183
+ if (input === 'e' && rows[cursor] !== undefined) {
184
+ return setExpanded(value => value === rows[cursor]!.id ? undefined : rows[cursor]!.id)
185
+ }
186
+ if (input === 't' && rows[cursor] !== undefined) {
187
+ const row = rows[cursor]!
188
+ transcriptLoad.current?.abort()
189
+ setTranscript({ id: row.id })
190
+ const controller = new AbortController()
191
+ transcriptLoad.current = controller
192
+ Promise.resolve().then(() => readTranscript(row.id, controller.signal)).then(
193
+ text => { if (!controller.signal.aborted) setTranscript({ id: row.id, text }) },
194
+ reason => { if (!controller.signal.aborted) setTranscript({ id: row.id, error: reason instanceof Error ? reason.message : String(reason) }) },
195
+ )
196
+ return
197
+ }
198
+ if (key.return && rows[cursor]?.resumable === true) return select(rows[cursor]!)
199
+ const next = editQuery(options.query, input, key)
200
+ if (next !== undefined) { setOptions(value => ({ ...value, query: next })); setCursor(0) }
201
+ }, { isActive: transcript === undefined })
202
+ if (transcript !== undefined) {
203
+ return createElement(DocumentPanel, {
204
+ title: `transcript · ${transcript.id}`,
205
+ text: transcript.text,
206
+ error: transcript.error,
207
+ close: () => { transcriptLoad.current?.abort(); setTranscript(undefined) },
208
+ })
209
+ }
210
+ const toolbar = `[${focus === 0 ? '>' : ''}${options.sessions}] [${focus === 1 ? '>' : ''}${options.cwd} cwd] [${focus === 2 ? '>' : ''}${options.sort}] [${focus === 3 ? '>' : ''}${density}]`
211
+ return createElement(ListFrame, {
212
+ title: `/resume · ${toolbar}`,
213
+ rows: rows.map(row => ({
214
+ key: row.id,
215
+ disabled: !row.resumable,
216
+ text: `${row.subagent ? '↳' : '○'} ${row.title ?? row.id.slice(-12)}${density === 'comfortable' ? ` · ${row.workspace} · ${row.preset}` : ''}${row.live ? ' · live' : ''}${expanded === row.id ? ` · ${row.id} · ${row.cwd}${row.parent === undefined ? '' : ` · parent ${row.parent}`}` : ''}`,
217
+ })), cursor, loading, error, query: options.query,
218
+ footer: 'type search · tab/←→ filters · ↑↓/pg navigate · e details · t transcript · enter resume',
219
+ })
220
+ }
221
+
222
+ function DocumentPanel({ title, text, error, close }: {
223
+ title: string
224
+ text?: string
225
+ error?: string
226
+ close(): void
227
+ }): ReactElement {
228
+ const stdout = useStdout().stdout
229
+ const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
230
+ const [scroll, setScroll] = useState(0)
231
+ const lines = useMemo(() => text === undefined ? [] : textLines(text, viewport.contentColumns).map(line => line.segments.map(segment => segment.text).join('')), [text, viewport.contentColumns])
232
+ useInput((input, key) => {
233
+ if (key.escape || input === 'q' || input === 't') return close()
234
+ if (key.upArrow) return setScroll(value => Math.max(0, value - 1))
235
+ if (key.downArrow) return setScroll(value => Math.min(Math.max(0, lines.length - viewport.bodyRows), value + 1))
236
+ if (key.pageUp) return setScroll(value => Math.max(0, value - Math.max(1, viewport.bodyRows - 1)))
237
+ if (key.pageDown) return setScroll(value => Math.min(Math.max(0, lines.length - viewport.bodyRows), value + Math.max(1, viewport.bodyRows - 1)))
238
+ if (input === 'g') return setScroll(0)
239
+ if (input === 'G') return setScroll(Math.max(0, lines.length - viewport.bodyRows))
240
+ })
241
+ if (viewport.compact) return createElement(Text, { wrap: 'truncate-end' }, truncateColumns('transcript · esc close', viewport.contentColumns))
242
+ const body = error !== undefined
243
+ ? [`error: ${singleLineText(error)}`]
244
+ : text === undefined
245
+ ? ['loading transcript…']
246
+ : lines.slice(scroll, scroll + viewport.bodyRows)
247
+ return createElement(
248
+ Box,
249
+ { borderStyle: 'round', borderColor: color(TUI_RGB.dim), flexDirection: 'column', paddingX: 1 },
250
+ createElement(Text, { color: color(TUI_RGB.brandBright), wrap: 'truncate-end' }, truncateColumns(title, viewport.contentColumns)),
251
+ ...body.map((line, index) => createElement(Text, { key: `${scroll}-${index}`, wrap: 'truncate-end' }, truncateColumns(line, viewport.contentColumns))),
252
+ createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(`lines ${lines.length === 0 ? 0 : scroll + 1}-${Math.min(lines.length, scroll + viewport.bodyRows)}/${lines.length} · ↑↓/pg/g/G · t/esc close`, viewport.contentColumns)),
253
+ )
254
+ }
@@ -0,0 +1,47 @@
1
+ /** Read-only projection of Cordis Loader entries for /plugin. */
2
+
3
+ import type { Context, FiberState } from '@deepseek-ai/cordis'
4
+
5
+ export type PluginPhase = 'pending' | 'loading' | 'active' | 'failed' | 'unloading' | null
6
+
7
+ export interface PluginRow {
8
+ readonly entryId: string
9
+ readonly moduleName: string
10
+ readonly enabled: boolean
11
+ readonly phase: PluginPhase
12
+ }
13
+
14
+ const PHASES: Record<number, PluginPhase> = {
15
+ 0: 'pending',
16
+ 1: 'loading',
17
+ 2: 'active',
18
+ 3: 'failed',
19
+ 4: null,
20
+ 5: 'unloading',
21
+ }
22
+
23
+ interface LoaderEntry {
24
+ readonly id: string
25
+ readonly disabled: boolean
26
+ readonly options: { readonly group?: boolean; readonly name: string }
27
+ readonly fiber?: { readonly state: FiberState }
28
+ }
29
+
30
+ /** Snapshot the live Loader; group-only rows are composition containers, not plugins. */
31
+ export function listPluginRows(ctx: Context): PluginRow[] {
32
+ const loader = (ctx as unknown as { get(name: string): unknown }).get('loader') as
33
+ | { entries(): Iterable<LoaderEntry> }
34
+ | undefined
35
+ if (loader === undefined) return []
36
+ const rows: PluginRow[] = []
37
+ for (const entry of loader.entries()) {
38
+ if (entry.options.group === true) continue
39
+ rows.push({
40
+ entryId: entry.id,
41
+ moduleName: entry.options.name,
42
+ enabled: !entry.disabled,
43
+ phase: entry.fiber === undefined ? null : PHASES[entry.fiber.state] ?? null,
44
+ })
45
+ }
46
+ return rows
47
+ }
package/src/presets.ts ADDED
@@ -0,0 +1,64 @@
1
+ /** Agent-preset policy kept independent from the Ink surface. */
2
+
3
+ import type { Context } from '@deepseek-ai/cordis'
4
+ import type { Agent, AgentHandle } from '@deepseek-ai/dsh-agent'
5
+ import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
6
+
7
+ /** One discoverable agent composition. */
8
+ export interface PresetRow {
9
+ readonly id: string
10
+ readonly trust: 'system' | 'user'
11
+ readonly name?: string
12
+ readonly description?: string
13
+ readonly order?: number
14
+ readonly broken?: string
15
+ }
16
+
17
+ /** Structural boundary for the optional upstream AgentPresets service. */
18
+ export interface AgentPresetsService {
19
+ readonly defaultId: string
20
+ list(): Promise<PresetRow[]>
21
+ resolve(id?: string): Promise<PresetRow>
22
+ mount(agentCtx: Context, id?: string): Promise<PresetRow>
23
+ recompose(agentCtx: Context, id: string): Promise<PresetRow>
24
+ composedPreset(agentCtx: Context): string | undefined
25
+ }
26
+
27
+ /** Read an optional Cordis service without requiring its package at build time. */
28
+ export function agentPresetsFrom(ctx: Context): AgentPresetsService | undefined {
29
+ return (ctx as unknown as { get(name: string): unknown }).get('agentPresets') as AgentPresetsService | undefined
30
+ }
31
+
32
+ /** A preset may change only before the first durable turn begins. */
33
+ export function isBlankSession(events: readonly SessionEvent[]): boolean {
34
+ return !events.some(event => event.type === 'turn/start')
35
+ }
36
+
37
+ /** Latest logged selection wins; legacy sessions deliberately fall back to standard. */
38
+ export function resolvePreset(session: Pick<Session, 'header' | 'events'>): string {
39
+ for (let index = session.events.length - 1; index >= 0; index -= 1) {
40
+ const event = session.events[index] as unknown as { type: string; data?: { agentPreset?: string } }
41
+ if (event.type === 'agent-preset/selected' && event.data?.agentPreset !== undefined) {
42
+ return event.data.agentPreset
43
+ }
44
+ }
45
+ return session.header.agentPreset ?? 'standard'
46
+ }
47
+
48
+ /** Recompose atomically from the caller's perspective, logging only success. */
49
+ export async function switchPreset(
50
+ service: AgentPresetsService,
51
+ agent: Agent,
52
+ presetId: string,
53
+ ): Promise<PresetRow> {
54
+ if (!isBlankSession(agent.session.events)) {
55
+ throw new Error('mode is locked after the first turn; use /new <mode>')
56
+ }
57
+ const preset = await service.recompose(agent.ctx, presetId)
58
+ const writable = agent.session as unknown as { append(type: string, data: unknown): void }
59
+ writable.append('agent-preset/selected', { agentPreset: preset.id })
60
+ return preset
61
+ }
62
+
63
+ /** Minimal handle shape used by lifecycle tests without exposing Agent internals. */
64
+ export type OwnedAgent = Pick<AgentHandle, 'agent' | 'dispose'>
@@ -6,14 +6,21 @@ export interface InspectorViewport {
6
6
  maxHeight: number
7
7
  /** Rows available to the selected entry after border, title, and footer. */
8
8
  bodyRows: number
9
+ /** Optional blank rows separating title/body/footer on roomy terminals. */
10
+ gapRows: 0 | 2
9
11
  /** Columns available inside the horizontal border and padding. */
10
12
  contentColumns: number
11
13
  /** Tiny terminals use a borderless one-line close hint. */
12
14
  compact: boolean
13
15
  }
14
16
 
15
- /** The three-row read-only composer frame plus its one-row status footer. */
16
- const INSPECTOR_CHROME_ROWS = 4
17
+ /** Composer/status chrome plus one optional, fixed-height local notice row. */
18
+ const INSPECTOR_CHROME_ROWS = 5
19
+
20
+ /** One transcript-to-composer gutter, collapsed on short terminals. */
21
+ export function layoutGutterRows(rows: number): 0 | 1 {
22
+ return Math.max(1, Math.floor(rows)) >= 14 ? 1 : 0
23
+ }
17
24
 
18
25
  /**
19
26
  * Keep the inspector plus its persistent status/composer chrome below
@@ -27,13 +34,15 @@ export function panelViewport(columns: number, rows: number): InspectorViewport
27
34
  // scrollback into a tall dynamic panel. A one-row margin is insufficient:
28
35
  // the transition can still take the full-terminal rewrite path at rows - 1.
29
36
  const maxHeight = Math.max(0, Math.min(
30
- safeRows - 2 - INSPECTOR_CHROME_ROWS,
37
+ safeRows - 2 - INSPECTOR_CHROME_ROWS - layoutGutterRows(safeRows),
31
38
  Math.floor(safeRows / 2),
32
39
  ))
33
40
  const compact = maxHeight < 5 || safeColumns < 8
41
+ const gapRows = !compact && maxHeight >= 7 ? 2 : 0
34
42
  return {
35
43
  maxHeight,
36
- bodyRows: compact ? 0 : maxHeight - 4,
44
+ bodyRows: compact ? 0 : maxHeight - 4 - gapRows,
45
+ gapRows,
37
46
  contentColumns: compact ? Math.max(1, safeColumns - 1) : Math.max(1, safeColumns - 4),
38
47
  compact,
39
48
  }
@@ -166,11 +166,18 @@ const ORDERED = /^\s*(\d+)[.)]\s+(.*)$/u
166
166
  /** Render markdown text into styled lines of at most `width` columns. */
167
167
  export function renderMarkdown(text: string, width: number): readonly MdLine[] {
168
168
  const lines: MdLine[] = []
169
+ let separatorPending = false
169
170
  const push = (segments: readonly MdSegment[]): void => {
170
171
  for (const wrapped of wrapSegments(segments, Math.max(10, width))) {
171
172
  lines.push({ segments: merge(wrapped) })
172
173
  }
173
174
  }
175
+ const startBlock = (): void => {
176
+ if (separatorPending && lines.length > 0 && lines.at(-1)?.segments.length !== 0) {
177
+ lines.push({ segments: [] })
178
+ }
179
+ separatorPending = false
180
+ }
174
181
  const raw = text.replaceAll('\r', '')
175
182
  const source = raw.split('\n')
176
183
  let index = 0
@@ -178,6 +185,14 @@ export function renderMarkdown(text: string, width: number): readonly MdLine[] {
178
185
  const line = source[index] ?? ''
179
186
  index += 1
180
187
 
188
+ // Preserve one deliberate row between source blocks. Repeated blank
189
+ // lines collapse, and leading/trailing whitespace never grows output.
190
+ if (line.trim() === '') {
191
+ separatorPending = lines.length > 0
192
+ continue
193
+ }
194
+ startBlock()
195
+
181
196
  // Fenced code block: verbatim lines in code style, language label first.
182
197
  const fence = FENCE.exec(line)
183
198
  if (fence !== null) {
@@ -191,7 +206,6 @@ export function renderMarkdown(text: string, width: number): readonly MdLine[] {
191
206
  continue
192
207
  }
193
208
 
194
- if (line.trim() === '') continue
195
209
  if (RULE.test(line.trim())) {
196
210
  push([seg(` ${'─'.repeat(Math.max(1, Math.floor(width / 4)))}`, 'dim')])
197
211
  continue
@@ -63,6 +63,8 @@ export function cacheHitPercent(usage: TranscriptStats['usage']): number | null
63
63
  export interface StatusFacts {
64
64
  /** `provider/model` selection serving this session. */
65
65
  model: string
66
+ /** Agent preset composing this session. */
67
+ mode?: string
66
68
  /** Working-directory basename the session serves. */
67
69
  cwd: string
68
70
  /** Git branch name, empty outside a repository or on a detached HEAD file. */
@@ -96,6 +98,7 @@ export function buildStatusGroups(facts: StatusFacts, stats: TranscriptStats): s
96
98
  facts.plan ? '⧉ plan' : undefined,
97
99
  ].filter(part => part !== undefined && part !== '')
98
100
  if (identity.length > 0) groups.push(identity.join(' · '))
101
+ if (facts.mode !== undefined && facts.mode !== '') groups.push(`mode ${facts.mode}`)
99
102
  if (stats.turns > 0 || stats.steps > 0) {
100
103
  groups.push(`T${stats.turns} · S${stats.steps}`)
101
104
  const durations: string[] = []
@@ -23,12 +23,9 @@ 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
25
 
26
- /** A display-safe suffix bounded by terminal rows and columns. */
27
- export interface DisplayTail {
28
- /** Sanitized suffix suitable for direct terminal rendering. */
29
- text: string
30
- /** Whether content before the returned suffix was omitted. */
31
- truncated: boolean
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, ' ')
32
29
  }
33
30
 
34
31
  /** Terminal-cell width matching the TUI's existing CJK-aware wrapping rule. */
@@ -40,6 +37,37 @@ function cellWidth(text: string): number {
40
37
  return columns
41
38
  }
42
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
+
43
71
  /** Read one Unicode character immediately before `end`. */
44
72
  function previousCharacter(text: string, end: number): { char: string; start: number } {
45
73
  const last = text.charCodeAt(end - 1)
@@ -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
+ }