136 lines
5.4 KiB
TypeScript
136 lines
5.4 KiB
TypeScript
/**
|
|
* Zero-state helpers for the interactive chat channel: prompt-directory and
|
|
* Git-branch formatting, surface/tool-call derivations over the session log,
|
|
* session-reference context cards, the placeholder editor, and banner-reveal
|
|
* timing constants. None of these close over channel state.
|
|
* @module @deepseek-ai/dsh-tui/chat/helpers
|
|
*/
|
|
|
|
import { execFileSync } from 'node:child_process'
|
|
import { homedir } from 'node:os'
|
|
import { isAbsolute, relative, resolve, sep } from 'node:path'
|
|
import {
|
|
CURSOR_MARKER,
|
|
Editor,
|
|
truncateToWidth,
|
|
visibleWidth,
|
|
} from '@earendil-works/pi-tui'
|
|
import type { Session } from '@deepseek-ai/dsh-session'
|
|
import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess'
|
|
|
|
/** Editor that shows a placeholder without making it editable content. */
|
|
export class HintEditor extends Editor {
|
|
/** Placeholder shown in the empty input row; `undefined` hides it. */
|
|
hint: string | undefined
|
|
/** Prompt text rendered before the placeholder, matching the live prompt width. */
|
|
hintPrefix = ''
|
|
|
|
override render(width: number): string[] {
|
|
const lines = super.render(width)
|
|
if (this.hint === undefined || this.getText() !== '') return lines
|
|
const content = lines[0]
|
|
/* v8 ignore next -- Editor always renders one content row. */
|
|
if (content === undefined) return lines
|
|
const padding = ' '.repeat(this.getPaddingX())
|
|
/* v8 ignore next -- the mounted editor is focused whenever its empty-input hint is rendered. */
|
|
const marker = this.focused ? CURSOR_MARKER : ''
|
|
const available = Math.max(0, width - visibleWidth(padding) - visibleWidth(this.hintPrefix))
|
|
const placeholder = truncateToWidth(this.hint, available, '')
|
|
const used = visibleWidth(padding) + visibleWidth(this.hintPrefix) + visibleWidth(placeholder)
|
|
lines[0] = `${padding}${this.hintPrefix}${marker}${placeholder}${' '.repeat(Math.max(0, width - used))}`
|
|
return lines
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Format the session working directory as a prompt label: `~` for home,
|
|
* `~/rel` for a home-relative path, the raw path otherwise.
|
|
* @param cwd - operational working directory from the session header.
|
|
* @returns unescaped prompt label.
|
|
*/
|
|
export function formatCwd(cwd: string | undefined): string {
|
|
if (cwd === undefined) return 'cwd unset'
|
|
const home = homedir()
|
|
const rel = relative(resolve(home), resolve(cwd))
|
|
if (rel === '') return '~'
|
|
/* v8 ignore next -- Windows cross-drive coverage; POSIX relative() cannot return an absolute path. */
|
|
if (isAbsolute(rel)) return cwd
|
|
if (rel !== '..' && !rel.startsWith(`..${sep}`)) return `~${sep}${rel}`
|
|
return cwd
|
|
}
|
|
|
|
/**
|
|
* Resolve the current Git branch for the prompt context line.
|
|
* @param cwd - operational working directory to query.
|
|
* @returns branch name, or `undefined` outside a worktree or on any failure.
|
|
*/
|
|
export function gitBranch(cwd: string): string | undefined {
|
|
try {
|
|
const branch = execFileSync('git', ['branch', '--show-current'], {
|
|
cwd,
|
|
encoding: 'utf8',
|
|
env: scrubbedParentEnv(),
|
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
timeout: 1_000,
|
|
}).trim()
|
|
/* v8 ignore next -- detached-HEAD behavior is exercised by the runtime smoke, not the unit checkout. */
|
|
return branch === '' ? undefined : branch
|
|
} catch (_gitUnavailableOrOutsideWorktree) {
|
|
return undefined
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sequence numbers currently visible on the session surface.
|
|
* @param session - session whose surface nodes to read.
|
|
* @returns the set of visible event sequence numbers.
|
|
*/
|
|
export function activeSurfaceSeqs(session: Session): Set<number> {
|
|
return new Set(session.surface.nodes)
|
|
}
|
|
|
|
/**
|
|
* Tool-call ids whose owning assistant message is on the active surface.
|
|
* @param session - session whose events to scan.
|
|
* @param active - sequence numbers currently on the surface.
|
|
* @returns the set of active tool-call ids.
|
|
*/
|
|
export function activeToolCallIds(session: Session, active: ReadonlySet<number>): Set<string> {
|
|
const ids = new Set<string>()
|
|
for (const event of session.events) {
|
|
if (event.type !== 'assistant/message' || !active.has(event.seq)) continue
|
|
for (const block of event.data.message.content) {
|
|
if (block.type === 'tool-call') ids.add(block.id)
|
|
}
|
|
}
|
|
return ids
|
|
}
|
|
|
|
/**
|
|
* Read a session-reference context card's display labels from an event source.
|
|
* @param source - event source to inspect.
|
|
* @returns per-reference labels, or `undefined` when the source is not a reference card.
|
|
*/
|
|
export function sessionReferenceCard(source: unknown): string[] | undefined {
|
|
if (typeof source !== 'object' || source === null) return undefined
|
|
const record = source as Record<string, unknown>
|
|
if (record['kind'] !== 'session-reference' || !Array.isArray(record['references'])) return undefined
|
|
const references = record['references'] as unknown[]
|
|
const labels: string[] = []
|
|
for (const reference of references) {
|
|
if (typeof reference !== 'object' || reference === null) return undefined
|
|
const entry = reference as Record<string, unknown>
|
|
const sessionId = entry['sessionId']
|
|
const label = entry['label']
|
|
if (typeof sessionId !== 'string' || typeof label !== 'string') return undefined
|
|
labels.push(label === sessionId ? sessionId : `${label} (${sessionId})`)
|
|
}
|
|
return labels
|
|
}
|
|
|
|
/** Milliseconds between banner sweep-reveal frames (~60 fps). */
|
|
export const BANNER_REVEAL_INTERVAL_MS = 15
|
|
|
|
/** Number of sweep frames the banner reveal spreads the terminal width over. */
|
|
export const BANNER_REVEAL_STEPS = 24
|