dsh-code 0.1.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/src/index.ts ADDED
@@ -0,0 +1,146 @@
1
+ /**
2
+ * @deepseek-ai/dsh-tui — the interactive terminal driver. The bundle patch
3
+ * rides over dsh-base without Host, HTTP, or browser plugins; this runner
4
+ * creates one Agent through the core registry, mounts the Ink app (DeepSeek
5
+ * blue, whale wordmark), folds submitted prompts into the same durable
6
+ * session, streams `session/event` into the transcript, and on quit flushes
7
+ * and requests process exit.
8
+ *
9
+ * @module @deepseek-ai/dsh-tui
10
+ */
11
+
12
+ import { randomUUID } from 'node:crypto'
13
+ import { readFileSync } from 'node:fs'
14
+ import { basename, join } from 'node:path'
15
+ import { createElement } from 'react'
16
+ import type { Context } from '@deepseek-ai/cordis'
17
+ import { installModelSelection } from '@deepseek-ai/dsh-agent'
18
+ import type { ModelSelectionRef } from '@deepseek-ai/dsh-agent'
19
+ import type {} from '@deepseek-ai/dsh-agent-default-model'
20
+ import { createUserMessage } from '@deepseek-ai/dsh-llm'
21
+ import { SessionId } from '@deepseek-ai/dsh-session'
22
+ import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
23
+ // Empty type imports carry the loader Context merge for the settlement await
24
+ // and the cmdline Context merge for the appExit host value.
25
+ import type {} from '@deepseek-ai/cordis-plugin-loader'
26
+ import type {} from '@deepseek-ai/dsh-cmdline'
27
+ import { App } from './app.ts'
28
+ import { internals, type TuiMount } from './internals.ts'
29
+ import { createTranscriptStore } from './store.ts'
30
+
31
+ /** Stable Cordis plugin name. */
32
+ export const name = 'tui-runner'
33
+
34
+ /** Core services required before the interactive session can start. */
35
+ export const inject = ['agentDefaultModel', 'agents', 'sessions']
36
+
37
+ /** Process-facing effects of the runner: the Ink mount plus the launcher's exit request. */
38
+ interface TuiIo {
39
+ mount: typeof internals.mount
40
+ exit(code: number): void
41
+ }
42
+
43
+ /** Report an unexpected direct-driver failure and request a failing exit. */
44
+ function fail(io: TuiIo, error: unknown): void {
45
+ internals.stderr.write(`dsh: ${error instanceof Error ? error.message : String(error)}\n`)
46
+ io.exit(1)
47
+ }
48
+
49
+ /**
50
+ * Resolve the working directory's git branch for the status line.
51
+ * @param cwd - the session's working directory.
52
+ * @returns the branch name, or '' outside a repository or on a detached HEAD.
53
+ */
54
+ function gitBranch(cwd: string): string {
55
+ try {
56
+ const ref = readFileSync(join(cwd, '.git', 'HEAD'), 'utf8').trim().match(/^ref: refs\/heads\/(.+)$/)
57
+ return ref?.[1] ?? ''
58
+ } catch {
59
+ // Only the single HEAD read is attempted, so the sole reachable failure is
60
+ // a missing repository (or unreadable HEAD file): the branch group drops out.
61
+ return ''
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Run the interactive terminal session: create one Agent, mount the app, and
67
+ * keep the process alive until the user quits.
68
+ * @param ctx - plugin context carrying the Agent, default model, Session, and launcher IO services.
69
+ * @param io - process-facing effects.
70
+ */
71
+ async function run(ctx: Context, io: TuiIo): Promise<void> {
72
+ // Loader siblings mount concurrently. Await the complete application before
73
+ // creating an Agent so its scoped tools and adapters are not half-composed.
74
+ await ctx.get('loader')?.await()
75
+ const agents = ctx.get('agents')
76
+ const defaultModel = ctx.get('agentDefaultModel')
77
+ const sessions = ctx.get('sessions')
78
+ // Early process shutdown can dispose the tree while settlement is pending.
79
+ if (agents === undefined || defaultModel === undefined || sessions === undefined) return
80
+
81
+ const selection = defaultModel.currentSelection()
82
+ // This bundle composes no preset roster, so the model-facing rows sit in the
83
+ // host plane and the agent reads them from the global layer (mirrors dsh-headless).
84
+ const { agent } = await agents.create({
85
+ sessionId: SessionId(`session-${randomUUID()}`),
86
+ meta: { cwd: process.cwd() },
87
+ agentOptions: { provider: selection.provider, model: selection.model },
88
+ setup: (agentCtx) => {
89
+ const selected: ModelSelectionRef = { current: selection, assembled: undefined }
90
+ installModelSelection(agentCtx, selected)
91
+ },
92
+ })
93
+
94
+ const store = createTranscriptStore()
95
+ const off = ctx.on('session/event', (session: Session, event: SessionEvent) => {
96
+ if (session.id === agent.session.id) store.apply(event)
97
+ })
98
+
99
+ // The mount handle lives in a box: quit closes over it, while the mount
100
+ // itself is created after quit (the App element needs quit as a prop).
101
+ const mountRef: { current?: TuiMount } = {}
102
+ let quitting = false
103
+ const quit = (): void => {
104
+ if (quitting) return
105
+ quitting = true
106
+ off()
107
+ mountRef.current?.unmount()
108
+ void sessions.flush(agent.session)
109
+ .catch((flushError: unknown) => {
110
+ // The session log already carries every durable event; a failed flush
111
+ // must not trap the user in a dead terminal, so report and still exit.
112
+ internals.stderr.write(`dsh: session flush failed: ${flushError instanceof Error ? flushError.message : String(flushError)}\n`)
113
+ })
114
+ .then(() => { io.exit(0) })
115
+ }
116
+
117
+ mountRef.current = io.mount(createElement(App, {
118
+ store,
119
+ model: `${selection.provider}/${selection.model}`,
120
+ cwd: basename(process.cwd()),
121
+ branch: gitBranch(process.cwd()),
122
+ sessionId: agent.session.id.slice(-8),
123
+ onSubmit: (text: string) => {
124
+ agent.followup(createUserMessage({
125
+ content: [{ type: 'text', text }],
126
+ source: { kind: 'user' },
127
+ }))
128
+ },
129
+ onQuit: quit,
130
+ }))
131
+ }
132
+
133
+ /**
134
+ * Mount the interactive terminal driver.
135
+ * @param ctx - plugin context carrying core services and the launcher-provided exit request.
136
+ */
137
+ export function apply(ctx: Context): void {
138
+ // Read through the global service store, not the property proxy: appExit is
139
+ // an optional host value, never an injected dependency.
140
+ const exit = ctx.get('appExit')
141
+ if (exit === undefined) {
142
+ throw new Error('tui-runner: the launcher must provide ctx.appExit before the tree mounts')
143
+ }
144
+ const io: TuiIo = { mount: internals.mount, exit }
145
+ void run(ctx, io).catch((error: unknown) => { fail(io, error) })
146
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Injectable process-facing effects for the TUI runner. Tests substitute the
3
+ * Ink mount with a capturing fake and the streams with string sinks, keeping
4
+ * the runner's lifecycle testable without a terminal.
5
+ *
6
+ * @module @deepseek-ai/dsh-tui/internals
7
+ */
8
+
9
+ import { render } from 'ink'
10
+ import type { ReactElement } from 'react'
11
+
12
+ /** A mounted terminal app instance; the runner owns unmount ordering. */
13
+ export interface TuiMount {
14
+ /** Tear the terminal app down before flush and exit. */
15
+ unmount(): void
16
+ }
17
+
18
+ /** The Ink mount seam: renders the app element and returns its handle. */
19
+ export type Mount = (element: ReactElement) => TuiMount
20
+
21
+ /** Substitutable runner effects; production values write to the real terminal. */
22
+ export const internals: {
23
+ /** Ink renderer mount; tests substitute a fake that captures the element. */
24
+ mount: Mount
25
+ /** Diagnostics stream for direct-driver failures. */
26
+ stderr: { write(chunk: string): unknown }
27
+ } = {
28
+ mount: (element: ReactElement): TuiMount => {
29
+ const instance = render(element)
30
+ return {
31
+ unmount(): void {
32
+ instance.unmount()
33
+ },
34
+ }
35
+ },
36
+ stderr: process.stderr,
37
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Package-owned invariant companion for `@deepseek-ai/dsh-tui`.
3
+ * @module @deepseek-ai/dsh-tui/invariant
4
+ */
5
+
6
+ import type { Context } from '@deepseek-ai/cordis'
7
+ import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
8
+
9
+ const PACKAGE_NAME = '@deepseek-ai/dsh-tui'
10
+
11
+ /** Cordis companion plugin name. */
12
+ export const name = 'tui-invariant'
13
+ /** Service required before the companion can register. */
14
+ export const inject = ['invariants']
15
+
16
+ /**
17
+ * No runtime invariant beyond the projection's own contract: the TUI renders
18
+ * only from `session/event` (model-visible means logged), so the display
19
+ * relation the renderer could desync from is already asserted by the session
20
+ * log's projection invariants; this companion registers nothing.
21
+ */
22
+ const install: InvariantInstaller = () => {}
23
+
24
+ /**
25
+ * Register this package's invariant companion.
26
+ * @param ctx - Cordis context carrying the invariant service.
27
+ * @returns the installed registration's disposer after setup succeeds.
28
+ */
29
+ export const apply = (ctx: Context): Promise<() => void> =>
30
+ Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
@@ -0,0 +1,222 @@
1
+ /**
2
+ * Pure session-event-to-view projection for the TUI transcript: one reducer
3
+ * over {@link SessionEvent}s producing the ordered entries the renderer draws.
4
+ * Rendering never reads the session directly — this module owns the view
5
+ * model, so tests drive it with plain event arrays.
6
+ *
7
+ * @module @deepseek-ai/dsh-tui/render/projection
8
+ */
9
+
10
+ import { boundContextSummary, type ContentBlock } from '@deepseek-ai/dsh-llm'
11
+ import type { SessionEvent } from '@deepseek-ai/dsh-session'
12
+
13
+ /** One user prompt line. */
14
+ export interface UserEntry {
15
+ kind: 'user'
16
+ /** Joined text blocks of the user message. */
17
+ text: string
18
+ }
19
+
20
+ /** One assembled assistant reply. */
21
+ export interface AssistantEntry {
22
+ kind: 'assistant'
23
+ /** Joined text blocks of the assistant message. */
24
+ text: string
25
+ }
26
+
27
+ /** One model-requested tool invocation and its settled state. */
28
+ export interface ToolEntry {
29
+ kind: 'tool'
30
+ /** Correlation id shared with the matching `tool/result`. */
31
+ callId: string
32
+ /** Tool name as the model addressed it. */
33
+ name: string
34
+ /** Raw arguments JSON string exactly as the model produced it. */
35
+ arguments: string
36
+ /** Execution state; `running` until the paired result lands. */
37
+ state: 'running' | 'done' | 'error'
38
+ /** Bounded first text block of the result, empty until it lands. */
39
+ summary: string
40
+ }
41
+
42
+ /** One turn-level failure surfaced from `turn/end`. */
43
+ export interface ErrorEntry {
44
+ kind: 'error'
45
+ /** `code: message` of the failure. */
46
+ text: string
47
+ }
48
+
49
+ /** Ordered transcript items the renderer draws. */
50
+ export type TranscriptEntry = UserEntry | AssistantEntry | ToolEntry | ErrorEntry
51
+
52
+ /** Cumulative token accounting folded from `assistant/message` usage reports. */
53
+ export interface UsageTotals {
54
+ /** Prompt-side billed tokens: `inputTokens` plus both cache buckets. */
55
+ inputTokens: number
56
+ /** Completion-side tokens over the whole log. */
57
+ outputTokens: number
58
+ /** Cache-read tokens over the whole log (0 when the adapter reports none). */
59
+ cacheReadTokens: number
60
+ }
61
+
62
+ /** Window-scoped figures the status line shows; timing uses event timestamps. */
63
+ export interface TranscriptStats {
64
+ /** Durable turns opened (`turn/start` events). */
65
+ turns: number
66
+ /** Model requests made (`step/start` events). */
67
+ steps: number
68
+ /** Summed model wall time: `step/start` → `assistant/message`, in ms. */
69
+ llmMs: number
70
+ /** Summed tool wall time: `tool/call` → `tool/result`, in ms. */
71
+ toolMs: number
72
+ /** Cumulative token accounting; input stays 0 until a report lands. */
73
+ usage: UsageTotals
74
+ }
75
+
76
+ /** The complete TUI transcript view for one session. */
77
+ export interface TranscriptView {
78
+ /** Settled entries in log order. */
79
+ entries: readonly TranscriptEntry[]
80
+ /** Text accumulated from `assistant/chunk` deltas since the last flush. */
81
+ streaming: string
82
+ /** Latest whole-list todo snapshot from `todo/write`, empty when none. */
83
+ todos: SessionEvent<'todo/write'>['data']['todos']
84
+ /** True while a durable turn is open (`turn/start` … `turn/end`). */
85
+ busy: boolean
86
+ /** Figures the status line renders. */
87
+ stats: TranscriptStats
88
+ /**
89
+ * Fold-internal timing anchors, never rendered: open step and tool-call
90
+ * start timestamps the next `assistant/message` / `tool/result` resolves
91
+ * against. Keyed `turn:step` and by call id.
92
+ */
93
+ readonly anchors: { stepStart: Map<string, number>; toolStart: Map<string, number> }
94
+ }
95
+
96
+ /** Join the text blocks of a content list; non-text blocks contribute nothing. */
97
+ function textOf(content: readonly ContentBlock[]): string {
98
+ return content.filter(block => block.type === 'text').map(block => block.text).join('')
99
+ }
100
+
101
+ /** A fresh, empty transcript view. */
102
+ export function createTranscriptView(): TranscriptView {
103
+ return {
104
+ entries: [],
105
+ streaming: '',
106
+ todos: [],
107
+ busy: false,
108
+ stats: { turns: 0, steps: 0, llmMs: 0, toolMs: 0, usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 } },
109
+ anchors: { stepStart: new Map(), toolStart: new Map() },
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Fold one session event into an updated view (copy-on-write).
115
+ * @param view - the view before the event.
116
+ * @param event - one durable session event from `session/event` or the log.
117
+ * @returns the view after the event; the input view is never mutated.
118
+ */
119
+ export function projectEvent(view: TranscriptView, event: SessionEvent): TranscriptView {
120
+ switch (event.type) {
121
+ case 'user/message': {
122
+ // Injected context (plugin/model-continuation sources) stays collapsed
123
+ // to a bounded notice row, exactly like collapsed transcript context
124
+ // elsewhere in the product; only direct human prompts render in full.
125
+ const message = event.data
126
+ if (message.source.kind === 'user') {
127
+ return { ...view, entries: [...view.entries, { kind: 'user', text: textOf(message.content) }] }
128
+ }
129
+ const notice = message.source.kind === 'plugin' && message.source.form === 'notice'
130
+ ? message.source.summary
131
+ : message.source.kind
132
+ return { ...view, entries: [...view.entries, { kind: 'user', text: boundContextSummary(notice) }] }
133
+ }
134
+ case 'assistant/chunk': {
135
+ const chunk = event.data.chunk
136
+ if (chunk.type !== 'text-delta') return view
137
+ return { ...view, streaming: view.streaming + chunk.text }
138
+ }
139
+ case 'assistant/message': {
140
+ // The assembled message is authoritative; drop the streamed buffer.
141
+ const key = `${event.data.turn}:${event.data.step}`
142
+ const started = view.anchors.stepStart.get(key)
143
+ view.anchors.stepStart.delete(key)
144
+ const usage = event.data.usage
145
+ const totals = view.stats.usage
146
+ return {
147
+ ...view,
148
+ streaming: '',
149
+ entries: [...view.entries, { kind: 'assistant', text: textOf(event.data.message.content) }],
150
+ stats: {
151
+ ...view.stats,
152
+ llmMs: view.stats.llmMs + (started === undefined ? 0 : Math.max(0, event.time - started)),
153
+ usage: usage === undefined ? totals : {
154
+ inputTokens: totals.inputTokens + usage.inputTokens + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0),
155
+ outputTokens: totals.outputTokens + usage.outputTokens,
156
+ cacheReadTokens: totals.cacheReadTokens + (usage.cacheReadTokens ?? 0),
157
+ },
158
+ },
159
+ }
160
+ }
161
+ case 'tool/call': {
162
+ const data = event.data
163
+ view.anchors.toolStart.set(data.callId, event.time)
164
+ return {
165
+ ...view,
166
+ entries: [...view.entries, {
167
+ kind: 'tool',
168
+ callId: data.callId,
169
+ name: data.name,
170
+ arguments: data.arguments,
171
+ state: 'running',
172
+ summary: '',
173
+ }],
174
+ }
175
+ }
176
+ case 'tool/result': {
177
+ const block = event.data.message.content[0]
178
+ const started = view.anchors.toolStart.get(block.toolCallId)
179
+ view.anchors.toolStart.delete(block.toolCallId)
180
+ const summary = boundContextSummary(textOf(block.content))
181
+ const entries = view.entries.map((entry) => {
182
+ if (entry.kind !== 'tool' || entry.callId !== block.toolCallId) return entry
183
+ return { ...entry, state: block.isError === true ? 'error' as const : 'done' as const, summary }
184
+ })
185
+ return {
186
+ ...view,
187
+ entries,
188
+ stats: {
189
+ ...view.stats,
190
+ toolMs: view.stats.toolMs + (started === undefined ? 0 : Math.max(0, event.time - started)),
191
+ },
192
+ }
193
+ }
194
+ case 'todo/write':
195
+ return { ...view, todos: event.data.todos }
196
+ case 'turn/start':
197
+ return { ...view, busy: true, stats: { ...view.stats, turns: view.stats.turns + 1 } }
198
+ case 'step/start':
199
+ view.anchors.stepStart.set(`${event.data.turn}:${event.data.step}`, event.time)
200
+ return { ...view, stats: { ...view.stats, steps: view.stats.steps + 1 } }
201
+ case 'turn/end': {
202
+ const reason = event.data.reason
203
+ if (reason.kind !== 'error') return { ...view, busy: false }
204
+ return {
205
+ ...view,
206
+ busy: false,
207
+ entries: [...view.entries, { kind: 'error', text: `${reason.error.code}: ${reason.error.message}` }],
208
+ }
209
+ }
210
+ default:
211
+ return view
212
+ }
213
+ }
214
+
215
+ /**
216
+ * Fold a replayed event history into one view.
217
+ * @param events - events in `seq` order.
218
+ * @returns the folded view.
219
+ */
220
+ export function projectEvents(events: readonly SessionEvent[]): TranscriptView {
221
+ return events.reduce(projectEvent, createTranscriptView())
222
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Status-line composition for the TUI footer: pipe-separated groups blending
3
+ * the Claude-Code-style identity facts (model, working directory, git branch,
4
+ * session) with the web StatsLine's session figures (turns/steps, model and
5
+ * tool wall time, cache hit, token totals). Pure functions only — the footer
6
+ * renders exactly what {@link buildStatusGroups} returns.
7
+ *
8
+ * @module @deepseek-ai/dsh-tui/render/status
9
+ */
10
+
11
+ import type { TranscriptStats } from './projection.ts'
12
+
13
+ /**
14
+ * Compact token count: 517 / 12.2K / 517K / 1.2M (one decimal under three
15
+ * digits), mirroring the web composer's StatsLine format.
16
+ * @param n - token count.
17
+ * @returns display string.
18
+ */
19
+ export function formatTokens(n: number): string {
20
+ const scaled = (v: number): string =>
21
+ v >= 100 ? String(Math.round(v)) : String(Math.round(v * 10) / 10)
22
+ if (n < 1_000) return String(n)
23
+ if (n < 1_000_000) return `${scaled(n / 1_000)}K`
24
+ return `${scaled(n / 1_000_000)}M`
25
+ }
26
+
27
+ /**
28
+ * Compact duration: 45.2s under a minute, 2m42s from there on.
29
+ * @param ms - duration in milliseconds.
30
+ * @returns display string.
31
+ */
32
+ export function formatDuration(ms: number): string {
33
+ const s = ms / 1_000
34
+ if (s < 60) return `${Math.round(s * 10) / 10}s`
35
+ const whole = Math.round(s)
36
+ return `${Math.floor(whole / 60)}m${whole % 60}s`
37
+ }
38
+
39
+ /**
40
+ * Cache-hit share of billed prompt-side input.
41
+ * @param usage - cumulative token totals.
42
+ * @returns rounded integer percent, or null when no input was billed.
43
+ */
44
+ export function cacheHitPercent(usage: TranscriptStats['usage']): number | null {
45
+ return usage.inputTokens === 0
46
+ ? null
47
+ : Math.round(usage.cacheReadTokens / usage.inputTokens * 100)
48
+ }
49
+
50
+ /** Identity facts the runner resolves once at mount; empty strings drop out. */
51
+ export interface StatusFacts {
52
+ /** `provider/model` selection serving this session. */
53
+ model: string
54
+ /** Working-directory basename the session serves. */
55
+ cwd: string
56
+ /** Git branch name, empty outside a repository or on a detached HEAD file. */
57
+ branch: string
58
+ /** Short session identifier (last dash-separated segment or tail). */
59
+ sessionId: string
60
+ }
61
+
62
+ /**
63
+ * Build the footer's display groups; a group with no data drops out whole.
64
+ * @param facts - identity facts resolved by the runner.
65
+ * @param stats - session figures folded from the durable log.
66
+ * @returns one string per pipe-separated group, in display order.
67
+ */
68
+ export function buildStatusGroups(facts: StatusFacts, stats: TranscriptStats): string[] {
69
+ const groups: string[] = []
70
+ const identity = [facts.model, facts.cwd, facts.branch === '' ? undefined : `⑂ ${facts.branch}`]
71
+ .filter(part => part !== undefined && part !== '')
72
+ if (identity.length > 0) groups.push(identity.join(' · '))
73
+ if (stats.turns > 0 || stats.steps > 0) {
74
+ groups.push(`T${stats.turns} · S${stats.steps}`)
75
+ const durations: string[] = []
76
+ if (stats.llmMs > 0) durations.push(`llm ${formatDuration(stats.llmMs)}`)
77
+ if (stats.toolMs > 0) durations.push(`tool ${formatDuration(stats.toolMs)}`)
78
+ if (durations.length > 0) groups.push(durations.join(' · '))
79
+ }
80
+ const cacheHit = cacheHitPercent(stats.usage)
81
+ if (stats.usage.inputTokens > 0 || stats.usage.outputTokens > 0) {
82
+ if (cacheHit !== null) groups.push(`cache ${cacheHit}%`)
83
+ groups.push(`↑${formatTokens(stats.usage.inputTokens)} ↓${formatTokens(stats.usage.outputTokens)}`)
84
+ }
85
+ if (facts.sessionId !== '') groups.push(facts.sessionId)
86
+ return groups
87
+ }
package/src/store.ts ADDED
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Observable transcript store: folds session events into the projection view
3
+ * and notifies subscribers. The renderer subscribes through
4
+ * `useSyncExternalStore`; the runner owns event feeding. The store owns no
5
+ * timing — listeners fire synchronously after each applied event.
6
+ *
7
+ * @module @deepseek-ai/dsh-tui/store
8
+ */
9
+
10
+ import type { SessionEvent } from '@deepseek-ai/dsh-session'
11
+ import { createTranscriptView, projectEvent, type TranscriptView } from './render/projection.ts'
12
+
13
+ /** The externally readable, event-fed transcript store for one session. */
14
+ export interface TranscriptStore {
15
+ /** The current view; the same object identity until an event changes it. */
16
+ getView(): TranscriptView
17
+ /** Subscribe to view changes; returns the unsubscribe function. */
18
+ subscribe(listener: () => void): () => void
19
+ /** Fold one session event; ignored events change nothing and notify nobody. */
20
+ apply(event: SessionEvent): void
21
+ }
22
+
23
+ /**
24
+ * Create one transcript store.
25
+ * @returns the store the runner feeds and the renderer subscribes to.
26
+ */
27
+ export function createTranscriptStore(): TranscriptStore {
28
+ let view = createTranscriptView()
29
+ const listeners = new Set<() => void>()
30
+ return {
31
+ getView: () => view,
32
+ subscribe(listener: () => void): () => void {
33
+ listeners.add(listener)
34
+ return () => {
35
+ listeners.delete(listener)
36
+ }
37
+ },
38
+ apply(event: SessionEvent): void {
39
+ const next = projectEvent(view, event)
40
+ if (next === view) return
41
+ view = next
42
+ for (const listener of listeners) {
43
+ listener()
44
+ }
45
+ },
46
+ }
47
+ }
package/src/theme.ts ADDED
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Terminal color tokens for the dsh TUI, mapped from the product design
3
+ * platform's DeepSeek palette
4
+ * (`packages/client/ui-theme/src/styles/design-platform.css`). Truecolor RGB
5
+ * rides chalk, which degrades automatically on terminals without truecolor.
6
+ *
7
+ * @module @deepseek-ai/dsh-tui/theme
8
+ */
9
+
10
+ import chalk from 'chalk'
11
+
12
+ /**
13
+ * RGB triples for the TUI, one entry per design-platform token in use.
14
+ * Keep names and values in sync with the CSS custom properties cited inline.
15
+ */
16
+ export const TUI_RGB = {
17
+ /** Primary brand blue — `--dsw-static-deepseek-500`. */
18
+ brand: [65, 118, 230],
19
+ /** Brighter brand blue for live/streaming emphasis — `--dsw-static-deepseek-400`. */
20
+ brandBright: [103, 158, 254],
21
+ /** Deep brand blue for secondary chrome — `--dsw-static-deepseek-600`. */
22
+ brandDeep: [72, 104, 178],
23
+ /** Muted caption gray — `--dsw-static-neutral-bluish-600`. */
24
+ dim: [129, 133, 140],
25
+ /** Success green — `--dsw-static-green-500`. */
26
+ success: [34, 197, 94],
27
+ /** Error red — `--dsw-static-red-500`. */
28
+ error: [239, 68, 68],
29
+ /** Warning amber — `--dsw-static-amber-500`. */
30
+ warn: [245, 158, 11],
31
+ } as const satisfies Record<string, readonly [number, number, number]>
32
+
33
+ /** Paint with the primary brand blue: whale, wordmark, tool names, accents. */
34
+ export function brand(text: string): string {
35
+ return chalk.rgb(...TUI_RGB.brand)(text)
36
+ }
37
+
38
+ /** Paint with the bright brand blue: streaming output and active spinners. */
39
+ export function brandBright(text: string): string {
40
+ return chalk.rgb(...TUI_RGB.brandBright)(text)
41
+ }
42
+
43
+ /** Paint with the deep brand blue: borders and secondary chrome. */
44
+ export function brandDeep(text: string): string {
45
+ return chalk.rgb(...TUI_RGB.brandDeep)(text)
46
+ }
47
+
48
+ /** Paint muted captions, hints, and meta lines. */
49
+ export function dim(text: string): string {
50
+ return chalk.rgb(...TUI_RGB.dim)(text)
51
+ }
52
+
53
+ /** Paint completed tool results and confirmations. */
54
+ export function success(text: string): string {
55
+ return chalk.rgb(...TUI_RGB.success)(text)
56
+ }
57
+
58
+ /** Paint failures and error entries. */
59
+ export function error(text: string): string {
60
+ return chalk.rgb(...TUI_RGB.error)(text)
61
+ }
62
+
63
+ /** Paint warnings. */
64
+ export function warn(text: string): string {
65
+ return chalk.rgb(...TUI_RGB.warn)(text)
66
+ }
@@ -0,0 +1,23 @@
1
+ // GENERATED by scripts/gen-whale-glyph.ts — do not edit by hand. Rerun the
2
+ // generator after changing the FishLogo path. Half-block rendering of the
3
+ // DeepSeek fish logo (figma I39:24057;88:8943 fillGeometry, exact extract;
4
+ // native 23.16x17.04 → 26 columns × 8 half-block rows).
5
+ // Blank cells are part of the glyph's fixed 26-column grid: pad, never trim.
6
+
7
+ /** Half-block whale glyph rows; render with the brand color. */
8
+ export const WHALE_GLYPH: readonly string[] = [
9
+ ' ▄▄▄▄▄▄▄▄█ ▄█▄ ▄',
10
+ ' ▄▄██████████▄▄ ▀███▄████',
11
+ '▄███████████████▄ ███▀▀▀ ',
12
+ '██ ▀▀█████▄▀██████ ',
13
+ '██▄ ▀████▄▄████ ',
14
+ ' ██▄ ▀██████▀ ',
15
+ ' ▀██▄▄ ██▄ ▀███▄▄ ',
16
+ ' ▀▀███████▀▀ ▀▀▀ ',
17
+ ]
18
+
19
+ /** Fixed glyph width in terminal columns. */
20
+ export const WHALE_GLYPH_COLUMNS = 26
21
+
22
+ /** Fixed glyph height in half-block rows. */
23
+ export const WHALE_GLYPH_ROWS = 8