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/LICENSE +21 -0
- package/README.md +48 -0
- package/README.zh.md +48 -0
- package/cordis.patch.yml +28 -0
- package/lib/index.mjs +623 -0
- package/lib/invariant.mjs +21 -0
- package/lib/types/app.d.ts +34 -0
- package/lib/types/index.d.ts +20 -0
- package/lib/types/internals.d.ts +24 -0
- package/lib/types/invariant.d.ts +15 -0
- package/lib/types/render/projection.d.ts +102 -0
- package/lib/types/render/status.d.ts +47 -0
- package/lib/types/store.d.ts +24 -0
- package/lib/types/theme.d.ts +42 -0
- package/lib/types/whale-glyph.d.ts +6 -0
- package/package.json +69 -0
- package/src/app.ts +201 -0
- package/src/index.ts +146 -0
- package/src/internals.ts +37 -0
- package/src/invariant.ts +30 -0
- package/src/render/projection.ts +222 -0
- package/src/render/status.ts +87 -0
- package/src/store.ts +47 -0
- package/src/theme.ts +66 -0
- package/src/whale-glyph.ts +23 -0
|
@@ -0,0 +1,24 @@
|
|
|
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
|
+
import type { ReactElement } from 'react';
|
|
9
|
+
/** A mounted terminal app instance; the runner owns unmount ordering. */
|
|
10
|
+
export interface TuiMount {
|
|
11
|
+
/** Tear the terminal app down before flush and exit. */
|
|
12
|
+
unmount(): void;
|
|
13
|
+
}
|
|
14
|
+
/** The Ink mount seam: renders the app element and returns its handle. */
|
|
15
|
+
export type Mount = (element: ReactElement) => TuiMount;
|
|
16
|
+
/** Substitutable runner effects; production values write to the real terminal. */
|
|
17
|
+
export declare const internals: {
|
|
18
|
+
/** Ink renderer mount; tests substitute a fake that captures the element. */
|
|
19
|
+
mount: Mount;
|
|
20
|
+
/** Diagnostics stream for direct-driver failures. */
|
|
21
|
+
stderr: {
|
|
22
|
+
write(chunk: string): unknown;
|
|
23
|
+
};
|
|
24
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Package-owned invariant companion for `@deepseek-ai/dsh-tui`.
|
|
3
|
+
* @module @deepseek-ai/dsh-tui/invariant
|
|
4
|
+
*/
|
|
5
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
6
|
+
/** Cordis companion plugin name. */
|
|
7
|
+
export declare const name = "tui-invariant";
|
|
8
|
+
/** Service required before the companion can register. */
|
|
9
|
+
export declare const inject: string[];
|
|
10
|
+
/**
|
|
11
|
+
* Register this package's invariant companion.
|
|
12
|
+
* @param ctx - Cordis context carrying the invariant service.
|
|
13
|
+
* @returns the installed registration's disposer after setup succeeds.
|
|
14
|
+
*/
|
|
15
|
+
export declare const apply: (ctx: Context) => Promise<() => void>;
|
|
@@ -0,0 +1,102 @@
|
|
|
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
|
+
import type { SessionEvent } from '@deepseek-ai/dsh-session';
|
|
10
|
+
/** One user prompt line. */
|
|
11
|
+
export interface UserEntry {
|
|
12
|
+
kind: 'user';
|
|
13
|
+
/** Joined text blocks of the user message. */
|
|
14
|
+
text: string;
|
|
15
|
+
}
|
|
16
|
+
/** One assembled assistant reply. */
|
|
17
|
+
export interface AssistantEntry {
|
|
18
|
+
kind: 'assistant';
|
|
19
|
+
/** Joined text blocks of the assistant message. */
|
|
20
|
+
text: string;
|
|
21
|
+
}
|
|
22
|
+
/** One model-requested tool invocation and its settled state. */
|
|
23
|
+
export interface ToolEntry {
|
|
24
|
+
kind: 'tool';
|
|
25
|
+
/** Correlation id shared with the matching `tool/result`. */
|
|
26
|
+
callId: string;
|
|
27
|
+
/** Tool name as the model addressed it. */
|
|
28
|
+
name: string;
|
|
29
|
+
/** Raw arguments JSON string exactly as the model produced it. */
|
|
30
|
+
arguments: string;
|
|
31
|
+
/** Execution state; `running` until the paired result lands. */
|
|
32
|
+
state: 'running' | 'done' | 'error';
|
|
33
|
+
/** Bounded first text block of the result, empty until it lands. */
|
|
34
|
+
summary: string;
|
|
35
|
+
}
|
|
36
|
+
/** One turn-level failure surfaced from `turn/end`. */
|
|
37
|
+
export interface ErrorEntry {
|
|
38
|
+
kind: 'error';
|
|
39
|
+
/** `code: message` of the failure. */
|
|
40
|
+
text: string;
|
|
41
|
+
}
|
|
42
|
+
/** Ordered transcript items the renderer draws. */
|
|
43
|
+
export type TranscriptEntry = UserEntry | AssistantEntry | ToolEntry | ErrorEntry;
|
|
44
|
+
/** Cumulative token accounting folded from `assistant/message` usage reports. */
|
|
45
|
+
export interface UsageTotals {
|
|
46
|
+
/** Prompt-side billed tokens: `inputTokens` plus both cache buckets. */
|
|
47
|
+
inputTokens: number;
|
|
48
|
+
/** Completion-side tokens over the whole log. */
|
|
49
|
+
outputTokens: number;
|
|
50
|
+
/** Cache-read tokens over the whole log (0 when the adapter reports none). */
|
|
51
|
+
cacheReadTokens: number;
|
|
52
|
+
}
|
|
53
|
+
/** Window-scoped figures the status line shows; timing uses event timestamps. */
|
|
54
|
+
export interface TranscriptStats {
|
|
55
|
+
/** Durable turns opened (`turn/start` events). */
|
|
56
|
+
turns: number;
|
|
57
|
+
/** Model requests made (`step/start` events). */
|
|
58
|
+
steps: number;
|
|
59
|
+
/** Summed model wall time: `step/start` → `assistant/message`, in ms. */
|
|
60
|
+
llmMs: number;
|
|
61
|
+
/** Summed tool wall time: `tool/call` → `tool/result`, in ms. */
|
|
62
|
+
toolMs: number;
|
|
63
|
+
/** Cumulative token accounting; input stays 0 until a report lands. */
|
|
64
|
+
usage: UsageTotals;
|
|
65
|
+
}
|
|
66
|
+
/** The complete TUI transcript view for one session. */
|
|
67
|
+
export interface TranscriptView {
|
|
68
|
+
/** Settled entries in log order. */
|
|
69
|
+
entries: readonly TranscriptEntry[];
|
|
70
|
+
/** Text accumulated from `assistant/chunk` deltas since the last flush. */
|
|
71
|
+
streaming: string;
|
|
72
|
+
/** Latest whole-list todo snapshot from `todo/write`, empty when none. */
|
|
73
|
+
todos: SessionEvent<'todo/write'>['data']['todos'];
|
|
74
|
+
/** True while a durable turn is open (`turn/start` … `turn/end`). */
|
|
75
|
+
busy: boolean;
|
|
76
|
+
/** Figures the status line renders. */
|
|
77
|
+
stats: TranscriptStats;
|
|
78
|
+
/**
|
|
79
|
+
* Fold-internal timing anchors, never rendered: open step and tool-call
|
|
80
|
+
* start timestamps the next `assistant/message` / `tool/result` resolves
|
|
81
|
+
* against. Keyed `turn:step` and by call id.
|
|
82
|
+
*/
|
|
83
|
+
readonly anchors: {
|
|
84
|
+
stepStart: Map<string, number>;
|
|
85
|
+
toolStart: Map<string, number>;
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
/** A fresh, empty transcript view. */
|
|
89
|
+
export declare function createTranscriptView(): TranscriptView;
|
|
90
|
+
/**
|
|
91
|
+
* Fold one session event into an updated view (copy-on-write).
|
|
92
|
+
* @param view - the view before the event.
|
|
93
|
+
* @param event - one durable session event from `session/event` or the log.
|
|
94
|
+
* @returns the view after the event; the input view is never mutated.
|
|
95
|
+
*/
|
|
96
|
+
export declare function projectEvent(view: TranscriptView, event: SessionEvent): TranscriptView;
|
|
97
|
+
/**
|
|
98
|
+
* Fold a replayed event history into one view.
|
|
99
|
+
* @param events - events in `seq` order.
|
|
100
|
+
* @returns the folded view.
|
|
101
|
+
*/
|
|
102
|
+
export declare function projectEvents(events: readonly SessionEvent[]): TranscriptView;
|
|
@@ -0,0 +1,47 @@
|
|
|
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
|
+
import type { TranscriptStats } from './projection.ts';
|
|
11
|
+
/**
|
|
12
|
+
* Compact token count: 517 / 12.2K / 517K / 1.2M (one decimal under three
|
|
13
|
+
* digits), mirroring the web composer's StatsLine format.
|
|
14
|
+
* @param n - token count.
|
|
15
|
+
* @returns display string.
|
|
16
|
+
*/
|
|
17
|
+
export declare function formatTokens(n: number): string;
|
|
18
|
+
/**
|
|
19
|
+
* Compact duration: 45.2s under a minute, 2m42s from there on.
|
|
20
|
+
* @param ms - duration in milliseconds.
|
|
21
|
+
* @returns display string.
|
|
22
|
+
*/
|
|
23
|
+
export declare function formatDuration(ms: number): string;
|
|
24
|
+
/**
|
|
25
|
+
* Cache-hit share of billed prompt-side input.
|
|
26
|
+
* @param usage - cumulative token totals.
|
|
27
|
+
* @returns rounded integer percent, or null when no input was billed.
|
|
28
|
+
*/
|
|
29
|
+
export declare function cacheHitPercent(usage: TranscriptStats['usage']): number | null;
|
|
30
|
+
/** Identity facts the runner resolves once at mount; empty strings drop out. */
|
|
31
|
+
export interface StatusFacts {
|
|
32
|
+
/** `provider/model` selection serving this session. */
|
|
33
|
+
model: string;
|
|
34
|
+
/** Working-directory basename the session serves. */
|
|
35
|
+
cwd: string;
|
|
36
|
+
/** Git branch name, empty outside a repository or on a detached HEAD file. */
|
|
37
|
+
branch: string;
|
|
38
|
+
/** Short session identifier (last dash-separated segment or tail). */
|
|
39
|
+
sessionId: string;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Build the footer's display groups; a group with no data drops out whole.
|
|
43
|
+
* @param facts - identity facts resolved by the runner.
|
|
44
|
+
* @param stats - session figures folded from the durable log.
|
|
45
|
+
* @returns one string per pipe-separated group, in display order.
|
|
46
|
+
*/
|
|
47
|
+
export declare function buildStatusGroups(facts: StatusFacts, stats: TranscriptStats): string[];
|
|
@@ -0,0 +1,24 @@
|
|
|
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
|
+
import type { SessionEvent } from '@deepseek-ai/dsh-session';
|
|
10
|
+
import { type TranscriptView } from './render/projection.ts';
|
|
11
|
+
/** The externally readable, event-fed transcript store for one session. */
|
|
12
|
+
export interface TranscriptStore {
|
|
13
|
+
/** The current view; the same object identity until an event changes it. */
|
|
14
|
+
getView(): TranscriptView;
|
|
15
|
+
/** Subscribe to view changes; returns the unsubscribe function. */
|
|
16
|
+
subscribe(listener: () => void): () => void;
|
|
17
|
+
/** Fold one session event; ignored events change nothing and notify nobody. */
|
|
18
|
+
apply(event: SessionEvent): void;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Create one transcript store.
|
|
22
|
+
* @returns the store the runner feeds and the renderer subscribes to.
|
|
23
|
+
*/
|
|
24
|
+
export declare function createTranscriptStore(): TranscriptStore;
|
|
@@ -0,0 +1,42 @@
|
|
|
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
|
+
* RGB triples for the TUI, one entry per design-platform token in use.
|
|
11
|
+
* Keep names and values in sync with the CSS custom properties cited inline.
|
|
12
|
+
*/
|
|
13
|
+
export declare const TUI_RGB: {
|
|
14
|
+
/** Primary brand blue — `--dsw-static-deepseek-500`. */
|
|
15
|
+
readonly brand: readonly [65, 118, 230];
|
|
16
|
+
/** Brighter brand blue for live/streaming emphasis — `--dsw-static-deepseek-400`. */
|
|
17
|
+
readonly brandBright: readonly [103, 158, 254];
|
|
18
|
+
/** Deep brand blue for secondary chrome — `--dsw-static-deepseek-600`. */
|
|
19
|
+
readonly brandDeep: readonly [72, 104, 178];
|
|
20
|
+
/** Muted caption gray — `--dsw-static-neutral-bluish-600`. */
|
|
21
|
+
readonly dim: readonly [129, 133, 140];
|
|
22
|
+
/** Success green — `--dsw-static-green-500`. */
|
|
23
|
+
readonly success: readonly [34, 197, 94];
|
|
24
|
+
/** Error red — `--dsw-static-red-500`. */
|
|
25
|
+
readonly error: readonly [239, 68, 68];
|
|
26
|
+
/** Warning amber — `--dsw-static-amber-500`. */
|
|
27
|
+
readonly warn: readonly [245, 158, 11];
|
|
28
|
+
};
|
|
29
|
+
/** Paint with the primary brand blue: whale, wordmark, tool names, accents. */
|
|
30
|
+
export declare function brand(text: string): string;
|
|
31
|
+
/** Paint with the bright brand blue: streaming output and active spinners. */
|
|
32
|
+
export declare function brandBright(text: string): string;
|
|
33
|
+
/** Paint with the deep brand blue: borders and secondary chrome. */
|
|
34
|
+
export declare function brandDeep(text: string): string;
|
|
35
|
+
/** Paint muted captions, hints, and meta lines. */
|
|
36
|
+
export declare function dim(text: string): string;
|
|
37
|
+
/** Paint completed tool results and confirmations. */
|
|
38
|
+
export declare function success(text: string): string;
|
|
39
|
+
/** Paint failures and error entries. */
|
|
40
|
+
export declare function error(text: string): string;
|
|
41
|
+
/** Paint warnings. */
|
|
42
|
+
export declare function warn(text: string): string;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Half-block whale glyph rows; render with the brand color. */
|
|
2
|
+
export declare const WHALE_GLYPH: readonly string[];
|
|
3
|
+
/** Fixed glyph width in terminal columns. */
|
|
4
|
+
export declare const WHALE_GLYPH_COLUMNS = 26;
|
|
5
|
+
/** Fixed glyph height in half-block rows. */
|
|
6
|
+
export declare const WHALE_GLYPH_ROWS = 8;
|
package/package.json
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-code",
|
|
3
|
+
"description": "Claude-Code-style interactive TUI bundle for DeepSeek Harness (dsh): DeepSeek-blue whale banner, live session transcript, and a blended status line",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "lib/index.mjs",
|
|
7
|
+
"types": "lib/types/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./lib/types/index.d.ts",
|
|
11
|
+
"default": "./lib/index.mjs"
|
|
12
|
+
},
|
|
13
|
+
"./invariant": {
|
|
14
|
+
"types": "./lib/types/invariant.d.ts",
|
|
15
|
+
"default": "./lib/invariant.mjs"
|
|
16
|
+
},
|
|
17
|
+
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
18
|
+
"./src/*": "./src/*",
|
|
19
|
+
"./package.json": "./package.json"
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"lib",
|
|
23
|
+
"cordis.patch.yml",
|
|
24
|
+
"src"
|
|
25
|
+
],
|
|
26
|
+
"license": "MIT",
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "git+https://github.com/unlinearity/dsh-code.git"
|
|
30
|
+
},
|
|
31
|
+
"dsh": {
|
|
32
|
+
"bundle": {
|
|
33
|
+
"patch": "./cordis.patch.yml"
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"scripts": {
|
|
37
|
+
"build": "tsdown && tsc -p tsconfig.json",
|
|
38
|
+
"test": "vitest run",
|
|
39
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
40
|
+
"gen:whale": "tsx scripts/gen-whale-glyph.ts"
|
|
41
|
+
},
|
|
42
|
+
"engines": {
|
|
43
|
+
"node": "^22.19 || >=24"
|
|
44
|
+
},
|
|
45
|
+
"dependencies": {
|
|
46
|
+
"chalk": "^5.6.2",
|
|
47
|
+
"ink": "^5.2.1",
|
|
48
|
+
"react": "^18.3.1"
|
|
49
|
+
},
|
|
50
|
+
"peerDependencies": {
|
|
51
|
+
"@deepseek-ai/cordis": "^4.0.1",
|
|
52
|
+
"@deepseek-ai/cordis-plugin-loader": "^1.0.0-rc.5",
|
|
53
|
+
"@deepseek-ai/dsh-agent": "^0.1.0-rc.6",
|
|
54
|
+
"@deepseek-ai/dsh-agent-default-model": "^0.1.0-rc.6",
|
|
55
|
+
"@deepseek-ai/dsh-cmdline": "^0.1.0-rc.6",
|
|
56
|
+
"@deepseek-ai/dsh-code-runtime-worker-thread": "^0.1.0-rc.6",
|
|
57
|
+
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.6",
|
|
58
|
+
"@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
|
|
59
|
+
"@deepseek-ai/dsh-session": "^0.1.0-rc.6"
|
|
60
|
+
},
|
|
61
|
+
"devDependencies": {
|
|
62
|
+
"@types/node": "^24.0.0",
|
|
63
|
+
"@types/react": "~18.3.1",
|
|
64
|
+
"tsdown": "^0.22.2",
|
|
65
|
+
"tsx": "^4.22.4",
|
|
66
|
+
"typescript": "^5.9.0",
|
|
67
|
+
"vitest": "^3.0.0"
|
|
68
|
+
}
|
|
69
|
+
}
|
package/src/app.ts
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Ink terminal app: whale-and-wordmark header in DeepSeek blue, the live
|
|
3
|
+
* transcript, the streaming line, local notices, and the input box. All state
|
|
4
|
+
* arrives through the transcript store (derived from the durable session log)
|
|
5
|
+
* plus local input state; the app owns no session mutation of its own.
|
|
6
|
+
*
|
|
7
|
+
* Element construction uses `createElement` (not JSX): the `dsh` source launch
|
|
8
|
+
* compiles this file through tsx's ESM-only hook, which does not adopt this
|
|
9
|
+
* package's `jsx: react-jsx` compiler option, and the classic JSX runtime
|
|
10
|
+
* would demand a React global.
|
|
11
|
+
*
|
|
12
|
+
* @module @deepseek-ai/dsh-tui/app
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { createElement, useState, useSyncExternalStore, type ReactElement } from 'react'
|
|
16
|
+
import { Box, Text, useInput } from 'ink'
|
|
17
|
+
import { assertNever } from '@deepseek-ai/dsh-llm'
|
|
18
|
+
import { TUI_RGB, brand, dim, error as paintError } from './theme.ts'
|
|
19
|
+
import { WHALE_GLYPH, WHALE_GLYPH_COLUMNS } from './whale-glyph.ts'
|
|
20
|
+
import type { TranscriptStore } from './store.ts'
|
|
21
|
+
import type { TranscriptEntry } from './render/projection.ts'
|
|
22
|
+
import { buildStatusGroups, type StatusFacts } from './render/status.ts'
|
|
23
|
+
|
|
24
|
+
/** Props the runner hands the app; callbacks stay owned by the runner. */
|
|
25
|
+
export interface AppProps {
|
|
26
|
+
/** Event-fed transcript store for the live session. */
|
|
27
|
+
store: TranscriptStore
|
|
28
|
+
/** `provider/model` selection serving this session. */
|
|
29
|
+
model: string
|
|
30
|
+
/** Working-directory basename the session serves. */
|
|
31
|
+
cwd: string
|
|
32
|
+
/** Git branch name, empty outside a repository. */
|
|
33
|
+
branch: string
|
|
34
|
+
/** Short session identifier. */
|
|
35
|
+
sessionId: string
|
|
36
|
+
/** Submit one human prompt; the runner folds it into the session. */
|
|
37
|
+
onSubmit(text: string): void
|
|
38
|
+
/** Quit: unmount, flush, and request process exit. */
|
|
39
|
+
onQuit(): void
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Ink `color` string for one palette triple. */
|
|
43
|
+
function inkColor(triple: readonly [number, number, number]): string {
|
|
44
|
+
return `rgb(${triple[0]}, ${triple[1]}, ${triple[2]})`
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** One settled transcript row. */
|
|
48
|
+
function EntryLine({ entry }: { entry: TranscriptEntry }): ReactElement {
|
|
49
|
+
switch (entry.kind) {
|
|
50
|
+
case 'user':
|
|
51
|
+
return createElement(Text, null, brand('❯ '), entry.text)
|
|
52
|
+
case 'assistant':
|
|
53
|
+
return createElement(Text, null, entry.text)
|
|
54
|
+
case 'tool': {
|
|
55
|
+
const mark = entry.state === 'running'
|
|
56
|
+
? createElement(Text, { color: inkColor(TUI_RGB.brandBright) }, '◐')
|
|
57
|
+
: entry.state === 'error'
|
|
58
|
+
? createElement(Text, { color: inkColor(TUI_RGB.error) }, '⨯')
|
|
59
|
+
: createElement(Text, { color: inkColor(TUI_RGB.success) }, '⏺')
|
|
60
|
+
return createElement(
|
|
61
|
+
Text,
|
|
62
|
+
null,
|
|
63
|
+
mark,
|
|
64
|
+
' ',
|
|
65
|
+
brand(entry.name),
|
|
66
|
+
entry.summary === '' ? '' : ` ${dim(entry.summary)}`,
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
case 'error':
|
|
70
|
+
return createElement(Text, null, paintError(entry.text))
|
|
71
|
+
default:
|
|
72
|
+
return assertNever(entry, 'transcript entry kind')
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The whale wordmark header in DeepSeek blue, hugging its content width. */
|
|
77
|
+
function Header(): ReactElement {
|
|
78
|
+
return createElement(
|
|
79
|
+
Box,
|
|
80
|
+
// alignSelf shrinks the border to the whale-plus-wordmark content instead
|
|
81
|
+
// of stretching across the terminal and stranding empty space on the right
|
|
82
|
+
// (the compact-banner treatment the Claude Code welcome uses).
|
|
83
|
+
{ flexDirection: 'row', gap: 1, borderStyle: 'round', borderColor: inkColor(TUI_RGB.brand), paddingX: 1, alignSelf: 'flex-start' },
|
|
84
|
+
createElement(
|
|
85
|
+
Box,
|
|
86
|
+
{ flexDirection: 'column', width: WHALE_GLYPH_COLUMNS },
|
|
87
|
+
...WHALE_GLYPH.map((row, index) => createElement(Text, { key: index, color: inkColor(TUI_RGB.brand) }, row)),
|
|
88
|
+
),
|
|
89
|
+
createElement(
|
|
90
|
+
Box,
|
|
91
|
+
{ flexDirection: 'column', justifyContent: 'center' },
|
|
92
|
+
createElement(Text, { color: inkColor(TUI_RGB.brand), bold: true }, 'DeepSeek Harness'),
|
|
93
|
+
createElement(Text, { dimColor: true }, '/help commands · Ctrl+C quit'),
|
|
94
|
+
),
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The footer status line: Claude-Code-style identity facts (model, working
|
|
100
|
+
* directory, git branch, session) beside the web composer's session figures
|
|
101
|
+
* (turns/steps, model and tool wall time, cache hit, token totals), joined
|
|
102
|
+
* by brand-colored pipes.
|
|
103
|
+
*/
|
|
104
|
+
function StatusLine({ facts, stats, busy }: {
|
|
105
|
+
facts: StatusFacts
|
|
106
|
+
stats: Parameters<typeof buildStatusGroups>[1]
|
|
107
|
+
busy: boolean
|
|
108
|
+
}): ReactElement {
|
|
109
|
+
const groups = buildStatusGroups(facts, stats)
|
|
110
|
+
const children: ReactElement[] = [
|
|
111
|
+
busy
|
|
112
|
+
? createElement(Text, { color: inkColor(TUI_RGB.brandBright) }, '● ')
|
|
113
|
+
: createElement(Text, { color: inkColor(TUI_RGB.brand) }, '○ '),
|
|
114
|
+
]
|
|
115
|
+
groups.forEach((group, index) => {
|
|
116
|
+
if (index > 0) children.push(createElement(Text, { dimColor: true }, dim(' | ')))
|
|
117
|
+
children.push(createElement(Text, { dimColor: true }, group))
|
|
118
|
+
})
|
|
119
|
+
return createElement(Box, { paddingX: 1 }, ...children)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The prompt box: slash commands handled locally, other text submitted. */
|
|
123
|
+
function Input({ busy, onSubmit, onQuit }: {
|
|
124
|
+
busy: boolean
|
|
125
|
+
onSubmit(text: string): void
|
|
126
|
+
onQuit(): void
|
|
127
|
+
}): ReactElement {
|
|
128
|
+
const [value, setValue] = useState('')
|
|
129
|
+
const [notices, setNotices] = useState<readonly string[]>([])
|
|
130
|
+
useInput((input, key) => {
|
|
131
|
+
if (key.ctrl && (input === 'c' || input === 'd')) {
|
|
132
|
+
onQuit()
|
|
133
|
+
return
|
|
134
|
+
}
|
|
135
|
+
if (key.return) {
|
|
136
|
+
const text = value.trim()
|
|
137
|
+
setValue('')
|
|
138
|
+
if (text === '') return
|
|
139
|
+
if (text === '/quit') {
|
|
140
|
+
onQuit()
|
|
141
|
+
return
|
|
142
|
+
}
|
|
143
|
+
if (text === '/help') {
|
|
144
|
+
setNotices([...notices, '/help show commands · /clear clear the screen · /quit exit'])
|
|
145
|
+
return
|
|
146
|
+
}
|
|
147
|
+
if (text === '/clear') {
|
|
148
|
+
setNotices([])
|
|
149
|
+
console.clear()
|
|
150
|
+
return
|
|
151
|
+
}
|
|
152
|
+
if (busy) {
|
|
153
|
+
setNotices([...notices, 'the agent is working — wait for the turn to finish'])
|
|
154
|
+
return
|
|
155
|
+
}
|
|
156
|
+
onSubmit(text)
|
|
157
|
+
return
|
|
158
|
+
}
|
|
159
|
+
if (key.backspace || key.delete) {
|
|
160
|
+
setValue(value.slice(0, -1))
|
|
161
|
+
return
|
|
162
|
+
}
|
|
163
|
+
if (input !== '') {
|
|
164
|
+
setValue(value + input)
|
|
165
|
+
}
|
|
166
|
+
})
|
|
167
|
+
return createElement(
|
|
168
|
+
Box,
|
|
169
|
+
{ flexDirection: 'column' },
|
|
170
|
+
...notices.map((notice, index) => createElement(Text, { key: index, dimColor: true }, notice)),
|
|
171
|
+
createElement(
|
|
172
|
+
Box,
|
|
173
|
+
null,
|
|
174
|
+
createElement(Text, { color: inkColor(TUI_RGB.brand) }, busy ? '… ' : '❯ '),
|
|
175
|
+
createElement(Text, null, value),
|
|
176
|
+
),
|
|
177
|
+
)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** The whole terminal app; state arrives via the store, output via Ink. */
|
|
181
|
+
export function App({ store, model, cwd, branch, sessionId, onSubmit, onQuit }: AppProps): ReactElement {
|
|
182
|
+
const view = useSyncExternalStore(store.subscribe, store.getView)
|
|
183
|
+
return createElement(
|
|
184
|
+
Box,
|
|
185
|
+
{ flexDirection: 'column' },
|
|
186
|
+
createElement(Header),
|
|
187
|
+
createElement(
|
|
188
|
+
Box,
|
|
189
|
+
{ flexDirection: 'column', paddingX: 1 },
|
|
190
|
+
...view.entries.map((entry, index) => createElement(EntryLine, { key: index, entry })),
|
|
191
|
+
view.streaming !== '' ? createElement(Text, null, view.streaming) : undefined,
|
|
192
|
+
view.busy && view.streaming === '' ? createElement(Text, { dimColor: true }, 'thinking…') : undefined,
|
|
193
|
+
),
|
|
194
|
+
createElement(Input, { busy: view.busy, onSubmit, onQuit }),
|
|
195
|
+
createElement(StatusLine, {
|
|
196
|
+
facts: { model, cwd, branch, sessionId },
|
|
197
|
+
stats: view.stats,
|
|
198
|
+
busy: view.busy,
|
|
199
|
+
}),
|
|
200
|
+
)
|
|
201
|
+
}
|