@itookit/dsht 0.3.0 → 0.3.3
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.i18n.yaml +2 -2
- package/README.md +39 -23
- package/README.zh.md +40 -24
- package/dist/catalog/controller.d.ts +32 -0
- package/dist/catalog/controller.js +88 -0
- package/dist/catalog/index.d.ts +2 -0
- package/dist/catalog/index.js +2 -0
- package/dist/{cli.js → cli/index.js} +42 -29
- package/dist/controller/connection.d.ts +80 -0
- package/dist/controller/connection.js +190 -0
- package/dist/controller/controller.d.ts +269 -0
- package/dist/controller/controller.js +372 -0
- package/dist/controller/index.d.ts +5 -0
- package/dist/controller/index.js +3 -0
- package/dist/controller/memory-log.d.ts +35 -0
- package/dist/controller/memory-log.js +95 -0
- package/dist/cost/config.d.ts +17 -0
- package/dist/cost/config.js +68 -0
- package/dist/cost/controller.d.ts +41 -0
- package/dist/cost/controller.js +115 -0
- package/dist/cost/index.d.ts +10 -0
- package/dist/cost/index.js +8 -0
- package/dist/cost/ledger-files.d.ts +16 -0
- package/dist/cost/ledger-files.js +103 -0
- package/dist/cost/ledger.d.ts +78 -0
- package/dist/cost/ledger.js +146 -0
- package/dist/cost/pricing.d.ts +86 -0
- package/dist/cost/pricing.js +223 -0
- package/dist/cost/records.d.ts +17 -0
- package/dist/cost/records.js +79 -0
- package/dist/cost/scanner.d.ts +22 -0
- package/dist/cost/scanner.js +95 -0
- package/dist/cost/types.d.ts +85 -0
- package/dist/cost/types.js +7 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +2 -0
- package/dist/session/connection-view.d.ts +18 -0
- package/dist/session/connection-view.js +1 -0
- package/dist/{controller.d.ts → session/controller.d.ts} +102 -136
- package/dist/session/controller.js +616 -0
- package/dist/session/export-html.d.ts +9 -0
- package/dist/session/export-html.js +39 -0
- package/dist/{export.d.ts → session/export.d.ts} +1 -1
- package/dist/{export.js → session/export.js} +6 -20
- package/dist/{history.d.ts → session/history.d.ts} +17 -0
- package/dist/{history.js → session/history.js} +163 -2
- package/dist/session/index.d.ts +17 -0
- package/dist/session/index.js +10 -0
- package/dist/session/markdown.d.ts +39 -0
- package/dist/session/markdown.js +255 -0
- package/dist/session/math.d.ts +11 -0
- package/dist/session/math.js +82 -0
- package/dist/session/navigation.d.ts +37 -0
- package/dist/session/navigation.js +84 -0
- package/dist/{references.js → session/references.js} +1 -1
- package/dist/{telemetry.d.ts → session/telemetry.d.ts} +1 -1
- package/dist/{telemetry.js → session/telemetry.js} +1 -1
- package/dist/{transcript.d.ts → session/transcript.d.ts} +65 -1
- package/dist/{transcript.js → session/transcript.js} +141 -20
- package/dist/session/types.d.ts +18 -0
- package/dist/session/types.js +2 -0
- package/dist/state.d.ts +41 -0
- package/dist/state.js +9 -0
- package/dist/storage/directories.d.ts +14 -0
- package/dist/storage/directories.js +24 -0
- package/dist/storage/files.d.ts +51 -0
- package/dist/storage/files.js +152 -0
- package/dist/storage/heap-snapshot.d.ts +19 -0
- package/dist/storage/heap-snapshot.js +29 -0
- package/dist/storage/index.d.ts +4 -0
- package/dist/storage/index.js +4 -0
- package/dist/transport/auth.js +65 -0
- package/dist/transport/host.d.ts +13 -0
- package/dist/transport/host.js +1 -0
- package/dist/ui/app.d.ts +11 -0
- package/dist/ui/app.js +790 -0
- package/dist/ui/chat/header.d.ts +11 -0
- package/dist/ui/chat/header.js +14 -0
- package/dist/ui/chat/history-view.d.ts +12 -0
- package/dist/ui/chat/history-view.js +17 -0
- package/dist/ui/chat/status.d.ts +91 -0
- package/dist/ui/chat/status.js +386 -0
- package/dist/ui/chat/viewport.d.ts +17 -0
- package/dist/ui/chat/viewport.js +14 -0
- package/dist/ui/commands/parse.d.ts +99 -0
- package/dist/ui/commands/parse.js +126 -0
- package/dist/ui/commands/registry.d.ts +33 -0
- package/dist/ui/commands/registry.js +73 -0
- package/dist/ui/copy-mode.d.ts +4 -0
- package/dist/ui/copy-mode.js +6 -0
- package/dist/{cost-view.d.ts → ui/dialogs/cost.d.ts} +1 -1
- package/dist/{cost-view.js → ui/dialogs/cost.js} +6 -6
- package/dist/ui/dialogs/index.d.ts +120 -0
- package/dist/ui/dialogs/index.js +113 -0
- package/dist/ui/dialogs/picker.d.ts +18 -0
- package/dist/ui/dialogs/picker.js +38 -0
- package/dist/ui/frozen.d.ts +8 -0
- package/dist/ui/frozen.js +7 -0
- package/dist/ui/input/references.d.ts +12 -0
- package/dist/ui/input/references.js +15 -0
- package/dist/ui/mount.d.ts +6 -0
- package/dist/ui/mount.js +11 -0
- package/dist/{theme.d.ts → ui/theme/index.d.ts} +1 -1
- package/package.json +19 -13
- package/dist/app.d.ts +0 -21
- package/dist/app.js +0 -805
- package/dist/auth.js +0 -108
- package/dist/controller.js +0 -961
- package/dist/cost.d.ts +0 -119
- package/dist/cost.js +0 -313
- package/dist/history-view.d.ts +0 -8
- package/dist/history-view.js +0 -12
- package/dist/navigation.d.ts +0 -11
- package/dist/navigation.js +0 -36
- package/dist/status.d.ts +0 -28
- package/dist/status.js +0 -157
- /package/dist/{cli.d.ts → cli/index.d.ts} +0 -0
- /package/dist/{memory.d.ts → session/memory.d.ts} +0 -0
- /package/dist/{memory.js → session/memory.js} +0 -0
- /package/dist/{references.d.ts → session/references.d.ts} +0 -0
- /package/dist/{auth.d.ts → transport/auth.d.ts} +0 -0
- /package/dist/{client.d.ts → transport/client.d.ts} +0 -0
- /package/dist/{client.js → transport/client.js} +0 -0
- /package/dist/{endpoint.d.ts → transport/endpoint.d.ts} +0 -0
- /package/dist/{endpoint.js → transport/endpoint.js} +0 -0
- /package/dist/{wire.d.ts → transport/wire.d.ts} +0 -0
- /package/dist/{wire.js → transport/wire.js} +0 -0
- /package/dist/{input-history.d.ts → ui/input/history.d.ts} +0 -0
- /package/dist/{input-history.js → ui/input/history.js} +0 -0
- /package/dist/{input.d.ts → ui/input/input.d.ts} +0 -0
- /package/dist/{input.js → ui/input/input.js} +0 -0
- /package/dist/{mouse.d.ts → ui/input/mouse.d.ts} +0 -0
- /package/dist/{mouse.js → ui/input/mouse.js} +0 -0
- /package/dist/{theme.js → ui/theme/index.js} +0 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** Session title and optional preset label above the divider.
|
|
2
|
+
* @param props - Title, preset label, geometry and freeze identity.
|
|
3
|
+
* @returns The header rows.
|
|
4
|
+
*/
|
|
5
|
+
export declare function ChatHeader({ title, mode, width, frozen, identity }: {
|
|
6
|
+
title: string;
|
|
7
|
+
mode?: string;
|
|
8
|
+
width: number;
|
|
9
|
+
frozen: boolean;
|
|
10
|
+
identity: string;
|
|
11
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/** Fixed conversation header: session title, workspace, and the agent-preset label. */
|
|
3
|
+
import { Box, Text } from 'ink';
|
|
4
|
+
import { toolLine } from "../../session/transcript.js";
|
|
5
|
+
import { Frozen } from "../frozen.js";
|
|
6
|
+
import { useTheme } from "../theme/index.js";
|
|
7
|
+
/** Session title and optional preset label above the divider.
|
|
8
|
+
* @param props - Title, preset label, geometry and freeze identity.
|
|
9
|
+
* @returns The header rows.
|
|
10
|
+
*/
|
|
11
|
+
export function ChatHeader({ title, mode, width, frozen, identity }) {
|
|
12
|
+
const theme = useTheme();
|
|
13
|
+
return _jsx(Frozen, { frozen: frozen, identity: identity, children: _jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [_jsxs(Box, { width: width, children: [_jsx(Box, { flexGrow: 1, flexShrink: 1, minWidth: 0, children: _jsx(Text, { bold: true, color: theme.accent, wrap: "truncate-end", children: toolLine(title, width) }) }), width >= 60 && mode && _jsx(Box, { flexShrink: 0, marginLeft: 2, children: _jsx(Text, { color: theme.accent, children: toolLine(mode, Math.min(24, Math.floor(width / 3))) }) })] }), _jsx(Text, { dimColor: true, children: '─'.repeat(width) })] }) });
|
|
14
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { HistoryRow } from '../../session/history.ts';
|
|
2
|
+
/** Render a viewport with role, reasoning, tool and result colors from the selected theme.
|
|
3
|
+
*
|
|
4
|
+
* Rows are sibling text nodes rather than lines inside one text node: Ink measures and caches text per
|
|
5
|
+
* node, so a parent holding the whole viewport is re-measured and retained whenever one row changes.
|
|
6
|
+
* An empty row renders as a single space because Ink gives an empty text node no height.
|
|
7
|
+
* @param rows - Already wrapped, terminal-safe visible rows.
|
|
8
|
+
* @returns Colored rows with unchanged row geometry and plain-text role markers.
|
|
9
|
+
*/
|
|
10
|
+
export declare function HistoryViewport({ rows }: {
|
|
11
|
+
rows: HistoryRow[];
|
|
12
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
/** Only visible semantic rows become React nodes; ANSI colors are applied after remote text sanitation. */
|
|
3
|
+
import { Box, Text } from 'ink';
|
|
4
|
+
import { useTheme } from "../theme/index.js";
|
|
5
|
+
/** Render a viewport with role, reasoning, tool and result colors from the selected theme.
|
|
6
|
+
*
|
|
7
|
+
* Rows are sibling text nodes rather than lines inside one text node: Ink measures and caches text per
|
|
8
|
+
* node, so a parent holding the whole viewport is re-measured and retained whenever one row changes.
|
|
9
|
+
* An empty row renders as a single space because Ink gives an empty text node no height.
|
|
10
|
+
* @param rows - Already wrapped, terminal-safe visible rows.
|
|
11
|
+
* @returns Colored rows with unchanged row geometry and plain-text role markers.
|
|
12
|
+
*/
|
|
13
|
+
export function HistoryViewport({ rows }) {
|
|
14
|
+
const theme = useTheme();
|
|
15
|
+
return _jsx(Box, { flexDirection: "column", children: rows.map((row, index) => _jsx(Text, { color: theme.colors[row.kind], bold: row.bold, children: row.spans?.length ? row.spans.map((span, position) => _jsx(Text, { bold: span.bold, italic: span.italic, underline: span.underline, strikethrough: span.strikethrough, inverse: span.inverse, children: span.text }, position))
|
|
16
|
+
: row.text === '' ? ' ' : row.text }, index)) });
|
|
17
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { Controller } from '../../controller/controller.ts';
|
|
2
|
+
import { type ObjectValue } from '../../transport/wire.ts';
|
|
3
|
+
/** Format elapsed wall time, clamping clock skew instead of displaying negative durations.
|
|
4
|
+
* @param milliseconds - Elapsed duration.
|
|
5
|
+
* @returns Minute/second display, with hours when needed.
|
|
6
|
+
*/
|
|
7
|
+
export declare function elapsedTime(milliseconds: number): string;
|
|
8
|
+
/** Produce compact metadata lines without inferring missing provider measurements.
|
|
9
|
+
*
|
|
10
|
+
* Counts use the compact form the single-row bar already uses, because a full count of a long
|
|
11
|
+
* session is eight digits wide and pushes each line past the terminal width on its own.
|
|
12
|
+
* @param values - Current host projection values.
|
|
13
|
+
* @param defaultModel - Host catalog default used before a session selects a route.
|
|
14
|
+
* @param running - Whether the current route or next route is primary.
|
|
15
|
+
* @returns Model, approximate context occupancy, and cumulative token buckets.
|
|
16
|
+
*/
|
|
17
|
+
export declare function metricLines(values: ObjectValue, defaultModel: ObjectValue | undefined, running: boolean): string[];
|
|
18
|
+
/** The stripe of work the live attempt is in, as the status bar shows it. */
|
|
19
|
+
export interface StatusSegment {
|
|
20
|
+
text: string;
|
|
21
|
+
color?: string;
|
|
22
|
+
}
|
|
23
|
+
/** Every group the single-row bar can show, in the order the packer keeps them. */
|
|
24
|
+
export interface StatusGroups {
|
|
25
|
+
/** Always shown: the running clock, Ready, a freeze reason, or the offline/error takeover. */
|
|
26
|
+
state: StatusSegment;
|
|
27
|
+
/** What the live attempt is doing now, e.g. `bash 1:08` or `think 28s`. */
|
|
28
|
+
phase?: StatusSegment;
|
|
29
|
+
/** How to stop the running turn. */
|
|
30
|
+
stop?: StatusSegment;
|
|
31
|
+
/** This session's cost with today's spend in parentheses, the only money the bar reports. */
|
|
32
|
+
cost?: StatusSegment;
|
|
33
|
+
/** Context share, labelled because a bare percentage next to money reads as a budget. */
|
|
34
|
+
context?: StatusSegment;
|
|
35
|
+
/** The same share drawn as a bar; used only where it still fits beside every other kept group. */
|
|
36
|
+
contextBar?: StatusSegment;
|
|
37
|
+
/** Model name without its reasoning effort, which is dropped first. */
|
|
38
|
+
model?: StatusSegment;
|
|
39
|
+
/** Reasoning effort, kept only while the model name still fits beside it. */
|
|
40
|
+
effort?: StatusSegment;
|
|
41
|
+
/** Completed turns. */
|
|
42
|
+
turns?: StatusSegment;
|
|
43
|
+
/** Cumulative tokens. */
|
|
44
|
+
tokens?: StatusSegment;
|
|
45
|
+
/** Cache-hit share of billed prompt input; it restates the token total, so the packer drops it first. */
|
|
46
|
+
cache?: StatusSegment;
|
|
47
|
+
}
|
|
48
|
+
/** Cache-hit share of billed prompt input, without reporting a partial hit as a full one.
|
|
49
|
+
*
|
|
50
|
+
* A prefix served from cache is what the provider charges least for, so the share is the reading
|
|
51
|
+
* that explains a cheap session; rounding it up to `100%` would instead claim that input stopped
|
|
52
|
+
* being billed, so the display gains precision until the number it shows is below a full hit.
|
|
53
|
+
* @param cacheReadTokens - Prompt tokens the provider served from cache.
|
|
54
|
+
* @param billedInputTokens - Uncached input, cache read and cache write, the prompt tokens that were billed.
|
|
55
|
+
* @returns Percentage text, or undefined when no prompt input has been billed yet.
|
|
56
|
+
*/
|
|
57
|
+
export declare function cacheHitText(cacheReadTokens: number | undefined, billedInputTokens: number | undefined): string | undefined;
|
|
58
|
+
/** Elapsed time as `m:ss`, adding hours only when they exist.
|
|
59
|
+
* @param milliseconds - Duration to format.
|
|
60
|
+
* @returns Clock text without a leading zero on minutes.
|
|
61
|
+
*/
|
|
62
|
+
export declare function clockText(milliseconds: number): string;
|
|
63
|
+
/** Age of a phase, in seconds while it is short and as a clock afterwards.
|
|
64
|
+
* @param milliseconds - Phase duration to format.
|
|
65
|
+
* @returns `28s` below a minute, otherwise `1:08`.
|
|
66
|
+
*/
|
|
67
|
+
export declare function phaseText(milliseconds: number): string;
|
|
68
|
+
/** Fit the status groups into one row, or two when the state and the cost cannot share one.
|
|
69
|
+
*
|
|
70
|
+
* Groups are kept by value, not by column: the least valuable group is dropped first, so any width
|
|
71
|
+
* degrades continuously instead of snapping to a fixed layout. The cost is never dropped, only
|
|
72
|
+
* moved to the second row, because it is one of the few answers this bar exists to give.
|
|
73
|
+
* @param groups - Group texts, each already formatted for display.
|
|
74
|
+
* @param width - Available terminal columns.
|
|
75
|
+
* @returns One or two rows of segments; separators carry no colour of their own.
|
|
76
|
+
*/
|
|
77
|
+
export declare function compactStatusRows(groups: StatusGroups, width: number): StatusSegment[][];
|
|
78
|
+
/** Render a live clock and selected-session metadata; the timer belongs to this mounted bar. */
|
|
79
|
+
export declare const StatusBar: import("react").NamedExoticComponent<{
|
|
80
|
+
controller: Controller;
|
|
81
|
+
expanded?: boolean;
|
|
82
|
+
width?: number;
|
|
83
|
+
revision?: number;
|
|
84
|
+
scroll?: number;
|
|
85
|
+
pageSize?: number;
|
|
86
|
+
onScroll?(next: number): void;
|
|
87
|
+
onOverflow?(overflow: boolean): void;
|
|
88
|
+
onRows?(rows: number): void;
|
|
89
|
+
/** Why the display is paused, so a frozen clock can say so instead of looking stalled. */
|
|
90
|
+
pauseReason?: "copy" | "dialog" | "history";
|
|
91
|
+
}>;
|
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/** Terminal status from host projections; cumulative usage and estimated context stay distinct. */
|
|
3
|
+
import { useTheme } from "../theme/index.js";
|
|
4
|
+
import { memo, useEffect, useState } from 'react';
|
|
5
|
+
import { Box, Text, useStdout } from 'ink';
|
|
6
|
+
import wrapAnsi from 'wrap-ansi';
|
|
7
|
+
import stringWidth from 'string-width';
|
|
8
|
+
import { costText } from "../../cost/index.js";
|
|
9
|
+
import { toolLine } from "../../session/transcript.js";
|
|
10
|
+
import { safeText } from "../../transport/wire.js";
|
|
11
|
+
/** Format elapsed wall time, clamping clock skew instead of displaying negative durations.
|
|
12
|
+
* @param milliseconds - Elapsed duration.
|
|
13
|
+
* @returns Minute/second display, with hours when needed.
|
|
14
|
+
*/
|
|
15
|
+
export function elapsedTime(milliseconds) {
|
|
16
|
+
const seconds = Math.max(0, Math.floor(milliseconds / 1000));
|
|
17
|
+
const minutes = Math.floor(seconds / 60);
|
|
18
|
+
if (minutes === 0)
|
|
19
|
+
return `${seconds}s`;
|
|
20
|
+
return `${minutes >= 60 ? `${Math.floor(minutes / 60)}h ` : ''}${minutes % 60}m ${seconds % 60}s`;
|
|
21
|
+
}
|
|
22
|
+
/** Compact token counts; one formatter is reused because construction dominates the format cost. */
|
|
23
|
+
const compactNumber = new Intl.NumberFormat('en', { notation: 'compact', maximumFractionDigits: 1 });
|
|
24
|
+
/** Produce compact metadata lines without inferring missing provider measurements.
|
|
25
|
+
*
|
|
26
|
+
* Counts use the compact form the single-row bar already uses, because a full count of a long
|
|
27
|
+
* session is eight digits wide and pushes each line past the terminal width on its own.
|
|
28
|
+
* @param values - Current host projection values.
|
|
29
|
+
* @param defaultModel - Host catalog default used before a session selects a route.
|
|
30
|
+
* @param running - Whether the current route or next route is primary.
|
|
31
|
+
* @returns Model, approximate context occupancy, and cumulative token buckets.
|
|
32
|
+
*/
|
|
33
|
+
export function metricLines(values, defaultModel, running) {
|
|
34
|
+
const selection = record(values.modelSelection);
|
|
35
|
+
const current = running ? selection.lastUsed ?? selection.next ?? defaultModel : selection.next ?? selection.lastUsed ?? defaultModel;
|
|
36
|
+
const model = modelName(current);
|
|
37
|
+
const next = modelName(selection.next);
|
|
38
|
+
const compact = (value) => value === undefined ? '?' : compactNumber.format(value);
|
|
39
|
+
const pressure = record(values.contextPressure);
|
|
40
|
+
const used = numeric(pressure.projectedTokens) ?? numeric(pressure.pressureTokens);
|
|
41
|
+
const capacity = numeric(pressure.contextWindow);
|
|
42
|
+
const context = used !== undefined && capacity !== undefined && capacity > 0
|
|
43
|
+
? `~${Math.min(100, Math.round(used / capacity * 100))}% (${compact(used)}/${compact(capacity)})`
|
|
44
|
+
: 'unknown';
|
|
45
|
+
const usage = record(values.tokenUsage);
|
|
46
|
+
const buckets = [usage.uncachedInputTokens, usage.outputTokens, usage.cacheReadTokens, usage.cacheWriteTokens].map(numeric);
|
|
47
|
+
const total = buckets.every(value => value !== undefined) ? buckets.reduce((a, b) => a + b, 0) : undefined;
|
|
48
|
+
return [
|
|
49
|
+
`Model: ${model}${running && next !== 'unknown' && next !== model ? ` · Next: ${next}` : ''}`,
|
|
50
|
+
`Context ${context} · ${compact(total)} tok`,
|
|
51
|
+
`In ${compact(buckets[0])} · Out ${compact(buckets[1])} · Cache ${compact(buckets[2])}/${compact(buckets[3])}`,
|
|
52
|
+
];
|
|
53
|
+
}
|
|
54
|
+
/** Cache-hit share of billed prompt input, without reporting a partial hit as a full one.
|
|
55
|
+
*
|
|
56
|
+
* A prefix served from cache is what the provider charges least for, so the share is the reading
|
|
57
|
+
* that explains a cheap session; rounding it up to `100%` would instead claim that input stopped
|
|
58
|
+
* being billed, so the display gains precision until the number it shows is below a full hit.
|
|
59
|
+
* @param cacheReadTokens - Prompt tokens the provider served from cache.
|
|
60
|
+
* @param billedInputTokens - Uncached input, cache read and cache write, the prompt tokens that were billed.
|
|
61
|
+
* @returns Percentage text, or undefined when no prompt input has been billed yet.
|
|
62
|
+
*/
|
|
63
|
+
export function cacheHitText(cacheReadTokens, billedInputTokens) {
|
|
64
|
+
if (cacheReadTokens === undefined || billedInputTokens === undefined || billedInputTokens <= 0)
|
|
65
|
+
return undefined;
|
|
66
|
+
if (cacheReadTokens >= billedInputTokens)
|
|
67
|
+
return '100%';
|
|
68
|
+
for (let digits = 0; digits <= 3; digits++) {
|
|
69
|
+
const text = (cacheReadTokens / billedInputTokens * 100).toFixed(digits);
|
|
70
|
+
if (Number(text) < 100)
|
|
71
|
+
return `${text}%`;
|
|
72
|
+
}
|
|
73
|
+
return '<100%';
|
|
74
|
+
}
|
|
75
|
+
/** Elapsed time as `m:ss`, adding hours only when they exist.
|
|
76
|
+
* @param milliseconds - Duration to format.
|
|
77
|
+
* @returns Clock text without a leading zero on minutes.
|
|
78
|
+
*/
|
|
79
|
+
export function clockText(milliseconds) {
|
|
80
|
+
const seconds = Math.max(0, Math.floor(milliseconds / 1000));
|
|
81
|
+
const minutes = Math.floor(seconds / 60);
|
|
82
|
+
if (minutes < 60)
|
|
83
|
+
return `${minutes}:${String(seconds % 60).padStart(2, '0')}`;
|
|
84
|
+
return `${Math.floor(minutes / 60)}:${String(minutes % 60).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`;
|
|
85
|
+
}
|
|
86
|
+
/** Age of a phase, in seconds while it is short and as a clock afterwards.
|
|
87
|
+
* @param milliseconds - Phase duration to format.
|
|
88
|
+
* @returns `28s` below a minute, otherwise `1:08`.
|
|
89
|
+
*/
|
|
90
|
+
export function phaseText(milliseconds) {
|
|
91
|
+
const seconds = Math.max(0, Math.floor(milliseconds / 1000));
|
|
92
|
+
return seconds < 60 ? `${seconds}s` : clockText(milliseconds);
|
|
93
|
+
}
|
|
94
|
+
/** Fit the status groups into one row, or two when the state and the cost cannot share one.
|
|
95
|
+
*
|
|
96
|
+
* Groups are kept by value, not by column: the least valuable group is dropped first, so any width
|
|
97
|
+
* degrades continuously instead of snapping to a fixed layout. The cost is never dropped, only
|
|
98
|
+
* moved to the second row, because it is one of the few answers this bar exists to give.
|
|
99
|
+
* @param groups - Group texts, each already formatted for display.
|
|
100
|
+
* @param width - Available terminal columns.
|
|
101
|
+
* @returns One or two rows of segments; separators carry no colour of their own.
|
|
102
|
+
*/
|
|
103
|
+
export function compactStatusRows(groups, width) {
|
|
104
|
+
if (width <= 0)
|
|
105
|
+
return [];
|
|
106
|
+
// Remote text reaches this bar, so every group is stripped of control characters before packing.
|
|
107
|
+
const clean = (group) => group === undefined ? undefined : { ...group, text: safeText(group.text).replace(/[\r\n\t]+/g, ' ') };
|
|
108
|
+
groups = { state: clean(groups.state), phase: clean(groups.phase), stop: clean(groups.stop), cost: clean(groups.cost),
|
|
109
|
+
context: clean(groups.context), contextBar: clean(groups.contextBar),
|
|
110
|
+
model: clean(groups.model), effort: clean(groups.effort), turns: clean(groups.turns), tokens: clean(groups.tokens),
|
|
111
|
+
cache: clean(groups.cache) };
|
|
112
|
+
const cluster = [groups.state, groups.phase, groups.stop].filter(Boolean);
|
|
113
|
+
// The cost is one group carrying two scopes: this session's spend, with today's in parentheses.
|
|
114
|
+
// Display order reads the model beside its effort and the money after the share it sits next to;
|
|
115
|
+
// keep order is by value, so a narrow bar holds the cost and the share before a model it cannot
|
|
116
|
+
// show, and the cache share goes before the token total it restates.
|
|
117
|
+
const order = [groups.model, groups.effort, groups.context, groups.cost, groups.turns, groups.tokens, groups.cache].filter(Boolean);
|
|
118
|
+
const rank = [groups.cost, groups.context, groups.model, groups.effort, groups.turns, groups.tokens, groups.cache].filter(Boolean);
|
|
119
|
+
// One row while enough of the sequence fits; each step drops the least valuable group first. The
|
|
120
|
+
// cost is never dropped while a row can hold it, only moved to the second one, so the search stops
|
|
121
|
+
// above it; a second row that cannot hold even the cost is not opened.
|
|
122
|
+
const floor = groups.cost === undefined ? 0 : 1;
|
|
123
|
+
for (let keep = rank.length; keep >= floor; keep--) {
|
|
124
|
+
const kept = new Set(rank.slice(0, keep));
|
|
125
|
+
const row = pack(cluster, order.filter(group => kept.has(group)));
|
|
126
|
+
if (measure(row) <= width)
|
|
127
|
+
return [keep > floor ? widen(row, groups, width) : row];
|
|
128
|
+
}
|
|
129
|
+
if (groups.cost === undefined && measure(pack(cluster, [])) > width)
|
|
130
|
+
return [fitCluster(cluster, width)];
|
|
131
|
+
// Not even the state cluster and the cost share a row, so the cost opens the second one.
|
|
132
|
+
const first = fitCluster(cluster, width);
|
|
133
|
+
const second = widen(packGreedy(rank, width), groups, width);
|
|
134
|
+
return second.length === 0 ? [first] : [first, second];
|
|
135
|
+
}
|
|
136
|
+
/** Offer the clearer reading of a group already on the row: the ten-cell context share.
|
|
137
|
+
*
|
|
138
|
+
* The bar is a rendering of the same share the plain percentage reports, not an additional group, so
|
|
139
|
+
* it never displaces a group that fits: below the width that holds it, the plain form keeps its place.
|
|
140
|
+
* @param row - A packed row that already fits.
|
|
141
|
+
* @param groups - Cleaned groups, used to find the pair by identity.
|
|
142
|
+
* @param width - Available terminal columns.
|
|
143
|
+
* @returns The row with the reading that fits.
|
|
144
|
+
*/
|
|
145
|
+
function widen(row, groups, width) {
|
|
146
|
+
return swap(row, groups.context, groups.contextBar, width);
|
|
147
|
+
}
|
|
148
|
+
/** Replace one group with its fuller rendering where the whole row still fits.
|
|
149
|
+
* @param row - A packed row that already fits.
|
|
150
|
+
* @param from - Group to replace, matched by identity.
|
|
151
|
+
* @param to - Fuller rendering of that group.
|
|
152
|
+
* @param width - Available terminal columns.
|
|
153
|
+
* @returns The row with the replacement, or the row unchanged where it does not fit.
|
|
154
|
+
*/
|
|
155
|
+
function swap(row, from, to, width) {
|
|
156
|
+
if (from === undefined || to === undefined)
|
|
157
|
+
return row;
|
|
158
|
+
const index = row.indexOf(from);
|
|
159
|
+
if (index < 0)
|
|
160
|
+
return row;
|
|
161
|
+
const replaced = [...row.slice(0, index), to, ...row.slice(index + 1)];
|
|
162
|
+
return measure(replaced) <= width ? replaced : row;
|
|
163
|
+
}
|
|
164
|
+
/** Fit the state cluster, dropping the phase and then the stop hint before truncating the state.
|
|
165
|
+
* @param cluster - State, phase and stop hint in that order.
|
|
166
|
+
* @param width - Available terminal columns.
|
|
167
|
+
* @returns The cluster as far as it fits.
|
|
168
|
+
*/
|
|
169
|
+
function fitCluster(cluster, width) {
|
|
170
|
+
for (let keep = cluster.length; keep >= 1; keep--) {
|
|
171
|
+
const row = pack(cluster.slice(0, keep), []);
|
|
172
|
+
if (measure(row) <= width)
|
|
173
|
+
return row;
|
|
174
|
+
}
|
|
175
|
+
const [first] = cluster;
|
|
176
|
+
return first === undefined ? [] : [{ ...first, text: toolLine(first.text, width) }];
|
|
177
|
+
}
|
|
178
|
+
/** Join the state cluster and the groups that follow it with their boundary separator.
|
|
179
|
+
* @param cluster - State, phase and stop hint, separated by middots.
|
|
180
|
+
* @param rest - Remaining groups, separated from the cluster by a bar.
|
|
181
|
+
* @returns The packed row.
|
|
182
|
+
*/
|
|
183
|
+
function pack(cluster, rest) {
|
|
184
|
+
return [...cluster, ...rest].flatMap((group, index) => index === 0 ? [group]
|
|
185
|
+
: [{ text: index === cluster.length ? ' │ ' : ' · ' }, group]);
|
|
186
|
+
}
|
|
187
|
+
/** Add leading groups while they fit, stopping at the first that does not.
|
|
188
|
+
* @param groups - Groups in keep order.
|
|
189
|
+
* @param width - Available terminal columns.
|
|
190
|
+
* @returns The groups that fit.
|
|
191
|
+
*/
|
|
192
|
+
function packGreedy(groups, width) {
|
|
193
|
+
let row = [];
|
|
194
|
+
for (const group of groups) {
|
|
195
|
+
const next = row.length === 0 ? [group] : [...row, { text: ' · ' }, group];
|
|
196
|
+
if (measure(next) > width)
|
|
197
|
+
break;
|
|
198
|
+
row = next;
|
|
199
|
+
}
|
|
200
|
+
return row;
|
|
201
|
+
}
|
|
202
|
+
/** Join groups with a separator, keeping the separator uncoloured.
|
|
203
|
+
* @param groups - Groups to join.
|
|
204
|
+
* @param separator - Separator text between them.
|
|
205
|
+
* @returns The groups with separators interleaved.
|
|
206
|
+
*/
|
|
207
|
+
function joinSegments(groups, separator) {
|
|
208
|
+
return groups.flatMap((group, index) => index === 0 ? [group] : [{ text: separator }, group]);
|
|
209
|
+
}
|
|
210
|
+
/** Display width of one packed row, so wide characters count as two columns.
|
|
211
|
+
* @param row - Segments already joined with their separators.
|
|
212
|
+
* @returns Columns the row occupies.
|
|
213
|
+
*/
|
|
214
|
+
function measure(row) {
|
|
215
|
+
return row.reduce((sum, segment) => sum + stringWidth(segment.text), 0);
|
|
216
|
+
}
|
|
217
|
+
/** Render a live clock and selected-session metadata; the timer belongs to this mounted bar. */
|
|
218
|
+
export const StatusBar = memo(function StatusBar({ controller, expanded = false, width, scroll = 0, pageSize, onScroll, onOverflow, onRows, pauseReason }) {
|
|
219
|
+
const paused = pauseReason !== undefined;
|
|
220
|
+
const theme = useTheme();
|
|
221
|
+
const { stdout } = useStdout();
|
|
222
|
+
const [now, setNow] = useState(Date.now);
|
|
223
|
+
const [reported, setReported] = useState(1);
|
|
224
|
+
const running = controller.running;
|
|
225
|
+
const since = controller.workingSince;
|
|
226
|
+
useEffect(() => {
|
|
227
|
+
setNow(Date.now());
|
|
228
|
+
if (paused)
|
|
229
|
+
return;
|
|
230
|
+
// An idle bar still re-reads the clock, so a day rollover reaches the cost it reports without
|
|
231
|
+
// waiting for an unrelated render; a running bar keeps its per-second clock.
|
|
232
|
+
const timer = setInterval(() => setNow(Date.now()), running ? 1000 : 60_000);
|
|
233
|
+
return () => clearInterval(timer);
|
|
234
|
+
}, [running, since, paused]);
|
|
235
|
+
const state = controller.state;
|
|
236
|
+
const workspace = state.workspaces.find(item => item.workspaceId === state.workspaceId);
|
|
237
|
+
const view = controller.telemetry.view(state.sessionId);
|
|
238
|
+
const costs = controller.costs;
|
|
239
|
+
const sessionCost = costs?.hasSession(state.sessionId) ? costText(costs.total(state.sessionId)) : '?';
|
|
240
|
+
const todayCost = costs ? costText(costs.today()) : '?';
|
|
241
|
+
// `*` belongs to costText alone; incomplete coverage is a separate degradation, reported by `!`.
|
|
242
|
+
const coverage = costs?.coverage ?? 'complete';
|
|
243
|
+
const label = workspace ? `${workspace.title} · ${workspace.path}` : 'none selected';
|
|
244
|
+
if (!expanded) {
|
|
245
|
+
const selection = record(view.values.modelSelection);
|
|
246
|
+
const route = record(running ? selection.lastUsed ?? selection.next ?? state.defaultModel : selection.next ?? selection.lastUsed ?? state.defaultModel);
|
|
247
|
+
const model = typeof route.model === 'string' ? route.model.replace(/^deepseek-/, '') : undefined;
|
|
248
|
+
const effort = typeof route.reasoningEffort === 'string' ? route.reasoningEffort : undefined;
|
|
249
|
+
const pressure = record(view.values.contextPressure);
|
|
250
|
+
const used = numeric(pressure.projectedTokens) ?? numeric(pressure.pressureTokens);
|
|
251
|
+
const capacity = numeric(pressure.contextWindow);
|
|
252
|
+
const percent = used !== undefined && capacity !== undefined && capacity > 0 ? Math.min(100, Math.round(used / capacity * 100)) : undefined;
|
|
253
|
+
const usage = record(view.values.tokenUsage);
|
|
254
|
+
const buckets = [usage.uncachedInputTokens, usage.outputTokens, usage.cacheReadTokens, usage.cacheWriteTokens].map(numeric);
|
|
255
|
+
const total = buckets.every(value => value !== undefined) ? buckets.reduce((a, b) => a + b, 0) : undefined;
|
|
256
|
+
// Billed prompt input is the three disjoint prompt buckets; a provider that reports no cache
|
|
257
|
+
// write has not billed one, which is how the ledger reads the same projection.
|
|
258
|
+
const billed = buckets[0] === undefined || buckets[2] === undefined ? undefined : buckets[0] + buckets[2] + (buckets[3] ?? 0);
|
|
259
|
+
const hit = cacheHitText(buckets[2], billed);
|
|
260
|
+
const compactCount = (value) => value === undefined ? '?' : compactNumber.format(value);
|
|
261
|
+
const phase = state.transcript.livePhase;
|
|
262
|
+
const clock = running && since !== undefined ? ` ${clockText(now - since)}` : '';
|
|
263
|
+
const phaseLabel = phase === undefined ? undefined
|
|
264
|
+
: phase.kind === 'tool' ? `${phase.name ?? 'tool'} ${phaseText(now - phase.startedAt)}`
|
|
265
|
+
: `${phase.kind === 'thinking' ? 'think' : 'write'} ${phaseText(now - phase.startedAt)}`;
|
|
266
|
+
// The state token reports a fact and never guesses: a paused clock is named, offline and errors
|
|
267
|
+
// take the token over, and an unknown phase simply leaves the phase group empty.
|
|
268
|
+
const stateToken = !state.online
|
|
269
|
+
? { text: '! Offline', color: theme.status.offline }
|
|
270
|
+
: state.controlError || state.modelError
|
|
271
|
+
? { text: '⚠ Error', color: theme.status.warning }
|
|
272
|
+
: pauseReason !== undefined
|
|
273
|
+
? { text: `⏸ ${pauseReason}${clock}`, color: theme.colors.muted }
|
|
274
|
+
: running ? { text: `◐${clock}`, color: theme.status.working } : { text: '● Ready', color: theme.status.ready };
|
|
275
|
+
// One marker covers both scopes, because either an unpriceable record or a scan that has not
|
|
276
|
+
// covered every session makes the pair inexact as a reading.
|
|
277
|
+
const inexact = coverage !== 'complete';
|
|
278
|
+
const money = (session, today) => `¥: ${session === undefined ? '?' : session.amount.toFixed(2)}(${today.amount.toFixed(2)})${session?.unknown || today.unknown || inexact ? '*' : ''}`;
|
|
279
|
+
const todayTotal = costs?.today();
|
|
280
|
+
const sessionTotal = costs !== undefined && costs.hasSession(state.sessionId) ? costs.total(state.sessionId) : undefined;
|
|
281
|
+
const contextColor = percent === undefined ? theme.status.usage
|
|
282
|
+
: percent >= 95 ? theme.status.critical : percent >= 80 ? theme.status.warning : theme.status.context;
|
|
283
|
+
// Ten cells resolve context to tenths, the same resolution the percentage beside them reports.
|
|
284
|
+
const cells = percent === undefined ? 0 : Math.round(percent / 10);
|
|
285
|
+
const groups = {
|
|
286
|
+
state: stateToken,
|
|
287
|
+
// A paused bar keeps the phase: the reason already says why the clock stopped, and dropping the
|
|
288
|
+
// running tool would leave the one question this bar exists to answer unanswered. A ready bar
|
|
289
|
+
// has none: the transcript keeps the last event it saw, and only the host knows the turn ended.
|
|
290
|
+
...(phaseLabel === undefined || !running ? {} : { phase: { text: phaseLabel } }),
|
|
291
|
+
...(running && pauseReason === undefined ? { stop: { text: '^C' } } : {}),
|
|
292
|
+
...(todayTotal === undefined ? {} : { cost: { text: money(sessionTotal, todayTotal), color: theme.status.cost } }),
|
|
293
|
+
...(percent === undefined ? {} : {
|
|
294
|
+
context: { text: `ctx ${percent}%`, color: contextColor },
|
|
295
|
+
contextBar: { text: `ctx: ${'█'.repeat(cells)}${'░'.repeat(10 - cells)} ~${percent}%`, color: contextColor }
|
|
296
|
+
}),
|
|
297
|
+
...(model === undefined ? {} : { model: { text: model, color: theme.status.model } }),
|
|
298
|
+
...(model === undefined || effort === undefined ? {} : { effort: { text: effort, color: theme.status.model } }),
|
|
299
|
+
...(numeric(record(view.values.sessionStats).turns) === undefined ? {} : { turns: { text: `${count(numeric(record(view.values.sessionStats).turns))} turns`, color: theme.status.usage } }),
|
|
300
|
+
...(total === undefined ? {} : { tokens: { text: `${compactCount(total)} tok`, color: theme.status.usage } }),
|
|
301
|
+
...(hit === undefined ? {} : { cache: { text: `hit ${hit}`, color: theme.status.usage } }),
|
|
302
|
+
};
|
|
303
|
+
const rows = compactStatusRows(groups, width ?? Math.max(1, (stdout.columns ?? 80) - 2));
|
|
304
|
+
if (rows.length !== reported) {
|
|
305
|
+
setReported(rows.length);
|
|
306
|
+
onRows?.(rows.length);
|
|
307
|
+
}
|
|
308
|
+
return _jsx(Box, { flexDirection: "column", children: rows.map((row, index) => _jsx(Text, { wrap: "truncate-end", children: row.map((segment, position) => _jsx(Text, { color: segment.color ?? theme.colors.muted, bold: index === 0 && position === 0, children: segment.text }, position)) }, index)) });
|
|
309
|
+
}
|
|
310
|
+
return _jsx(StatusDetails, { controller: controller, theme: theme, width: width, now: now, scroll: scroll, pageSize: pageSize, onScroll: onScroll, onOverflow: onOverflow });
|
|
311
|
+
});
|
|
312
|
+
/** Expanded detail panel.
|
|
313
|
+
*
|
|
314
|
+
* It is a separate component because it is the only branch that scrolls, and a hook behind the
|
|
315
|
+
* collapsed branch's early return would change the hook order between the two states.
|
|
316
|
+
*/
|
|
317
|
+
const StatusDetails = memo(function StatusDetails({ controller, theme, width, now, scroll, pageSize, onScroll, onOverflow }) {
|
|
318
|
+
const { stdout } = useStdout();
|
|
319
|
+
const state = controller.state;
|
|
320
|
+
const running = controller.running;
|
|
321
|
+
const since = controller.workingSince;
|
|
322
|
+
const workspace = state.workspaces.find(item => item.workspaceId === state.workspaceId);
|
|
323
|
+
const view = controller.telemetry.view(state.sessionId);
|
|
324
|
+
const costs = controller.costs;
|
|
325
|
+
const sessionCost = costs?.hasSession(state.sessionId) ? costText(costs.total(state.sessionId)) : '?';
|
|
326
|
+
const todayCost = costs ? costText(costs.today()) : '?';
|
|
327
|
+
// `*` belongs to costText alone; incomplete coverage is a separate degradation, reported by `!`.
|
|
328
|
+
const coverage = costs?.coverage ?? 'complete';
|
|
329
|
+
const label = workspace ? `${workspace.title} · ${workspace.path}` : 'none selected';
|
|
330
|
+
// Every detail row wraps to the terminal width, so a narrow terminal loses nothing; lines that
|
|
331
|
+
// still do not fit are scrolled rather than dropped, because the panel shares the screen height.
|
|
332
|
+
// Rows are merged and labelled compactly so a normal terminal shows every detail on one screen;
|
|
333
|
+
// the wrap and the scroll offset remain the fallback for a short or very narrow terminal.
|
|
334
|
+
const turns = count(numeric(record(view.values.sessionStats).turns));
|
|
335
|
+
const duration = since === undefined ? 'unknown duration' : elapsedTime(now - since);
|
|
336
|
+
const detail = [
|
|
337
|
+
{ key: 'activity', color: running ? theme.colors.context : theme.colors.muted, text: running
|
|
338
|
+
? `◐ Working · ${duration}${state.transcript.activeTurnStartedAt === undefined ? ' (observed)' : ''} · Ctrl+C Stop`
|
|
339
|
+
: '● Ready · Ctrl+C exit' },
|
|
340
|
+
{ key: 'host', text: `${safeText(controller.base)} · ${safeText(state.status)}${!state.online ? ' · offline, last known status' : ''}` },
|
|
341
|
+
...state.sessionId
|
|
342
|
+
? [{ key: 'session', text: `Session ${safeText(state.sessionId)}${controller.sessionMode ? ` · ${safeText(controller.sessionMode)}` : ''}` }] : [],
|
|
343
|
+
{ key: 'workspace', text: `Workspace ${safeText(label)}` },
|
|
344
|
+
...metricLines(view.values, state.defaultModel, running).map((line, index) => ({ key: `metric-${index}`, text: safeText(line), dim: true })),
|
|
345
|
+
...costs ? [{ key: 'cost', text: `Cost ${sessionCost} session · ${todayCost} today · ${turns} turns`, dim: true }] : [],
|
|
346
|
+
{ key: 'queued', text: `Queued ${count(view.queued)} · Jobs ${count(view.jobs)}${costs ? '' : ` · ${turns} turns`}`, dim: true },
|
|
347
|
+
...costs && coverage === 'partial'
|
|
348
|
+
? [{ key: 'coverage', color: theme.colors.context, text: `Cost coverage incomplete: ${costs.error ? safeText(costs.error) : 'no complete scan yet'}` }] : [],
|
|
349
|
+
...state.controlError ? [{ key: 'control-error', color: theme.colors.context, text: safeText(state.controlError) }] : [],
|
|
350
|
+
...state.presetError ? [{ key: 'preset-error', color: theme.colors.context, text: `Preset names unavailable: ${safeText(state.presetError)}` }] : [],
|
|
351
|
+
...state.modelError ? [{ key: 'model-error', color: theme.colors.context, text: `Model catalog unavailable: ${safeText(state.modelError)}` }] : [],
|
|
352
|
+
];
|
|
353
|
+
// Border and horizontal padding take four columns, so a detail row wraps inside what is left.
|
|
354
|
+
const inner = Math.max(1, (width ?? Math.max(3, (stdout.columns ?? 80) - 2)) - 4);
|
|
355
|
+
const measured = (text) => wrapAnsi(text, inner, { trim: false, hard: true }).split('\n').length;
|
|
356
|
+
const lines = detail.flatMap(row => wrapAnsi(row.text, inner, { trim: false, hard: true }).split('\n')
|
|
357
|
+
.map((text, index) => ({ ...row, key: `${row.key}:${index}`, text })));
|
|
358
|
+
const budget = Math.max(1, pageSize ?? lines.length);
|
|
359
|
+
const hint = (first, last, total) => `Status ${first}-${last}/${total} · ↑↓ scroll · Esc close`;
|
|
360
|
+
// A footer that wraps takes rows from the view, so its height is reserved before sizing it.
|
|
361
|
+
const size = Math.max(1, budget - (lines.length > budget ? measured(hint(0, 0, lines.length)) : 0));
|
|
362
|
+
const start = Math.max(0, Math.min(scroll, Math.max(0, lines.length - size)));
|
|
363
|
+
// Arrows and the wheel can ask for a line past either end; report the settled position back.
|
|
364
|
+
useEffect(() => { if (start !== scroll)
|
|
365
|
+
onScroll?.(start); }, [start, scroll, onScroll]);
|
|
366
|
+
// The caller needs to know whether this panel owns the arrows or has nothing to scroll.
|
|
367
|
+
useEffect(() => { onOverflow?.(lines.length > size); }, [lines.length, size, onOverflow]);
|
|
368
|
+
const visible = lines.slice(start, start + size);
|
|
369
|
+
return _jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: theme.border, paddingX: 1, children: [visible.map(line => _jsx(Text, { color: line.color, dimColor: line.dim, children: line.text }, line.key)), lines.length > size && _jsx(Text, { dimColor: true, children: hint(start + 1, start + visible.length, lines.length) })] });
|
|
370
|
+
});
|
|
371
|
+
function record(value) {
|
|
372
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
373
|
+
}
|
|
374
|
+
function numeric(value) {
|
|
375
|
+
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined;
|
|
376
|
+
}
|
|
377
|
+
function count(value) { return value === undefined ? '?' : value.toLocaleString('en-US'); }
|
|
378
|
+
function modelName(value) {
|
|
379
|
+
const model = record(value);
|
|
380
|
+
if (typeof model.model !== 'string' || typeof model.provider !== 'string')
|
|
381
|
+
return 'unknown';
|
|
382
|
+
return safeText(`${model.provider}/${model.model}${typeof model.reasoningEffort === 'string' ? ` (${model.reasoningEffort})` : ''}`);
|
|
383
|
+
}
|
|
384
|
+
function compactCost(total) {
|
|
385
|
+
return `~¥${total.amount.toFixed(2)}${total.unknown ? '*' : ''}`;
|
|
386
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** Measured conversation viewport: only visible rows become React nodes. */
|
|
2
|
+
import { type DOMElement } from 'ink';
|
|
3
|
+
import type { RefObject } from 'react';
|
|
4
|
+
import type { HistoryRow } from '../../session/history.ts';
|
|
5
|
+
/** Visible conversation rows plus the scroll hint.
|
|
6
|
+
* @param props - Rows, hint state, freeze identity and the measured box.
|
|
7
|
+
* @returns The scrollable conversation area.
|
|
8
|
+
*/
|
|
9
|
+
export declare function ChatViewport({ rows, showHistoryHint, dialogOpen, historyWindow, frozen, identity, boxRef }: {
|
|
10
|
+
rows: HistoryRow[];
|
|
11
|
+
showHistoryHint: boolean;
|
|
12
|
+
dialogOpen: boolean;
|
|
13
|
+
historyWindow: boolean;
|
|
14
|
+
frozen: boolean;
|
|
15
|
+
identity: string;
|
|
16
|
+
boxRef: RefObject<DOMElement | null>;
|
|
17
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/** Measured conversation viewport: only visible rows become React nodes. */
|
|
3
|
+
import { Box, Text } from 'ink';
|
|
4
|
+
import { Frozen } from "../frozen.js";
|
|
5
|
+
import { useTheme } from "../theme/index.js";
|
|
6
|
+
import { HistoryViewport } from "./history-view.js";
|
|
7
|
+
/** Visible conversation rows plus the scroll hint.
|
|
8
|
+
* @param props - Rows, hint state, freeze identity and the measured box.
|
|
9
|
+
* @returns The scrollable conversation area.
|
|
10
|
+
*/
|
|
11
|
+
export function ChatViewport({ rows, showHistoryHint, dialogOpen, historyWindow, frozen, identity, boxRef }) {
|
|
12
|
+
const theme = useTheme();
|
|
13
|
+
return _jsxs(Box, { ref: boxRef, flexDirection: "column", flexGrow: 1, flexShrink: 1, minHeight: 0, overflowY: "hidden", marginY: dialogOpen ? 0 : 1, children: [rows.length ? _jsx(Frozen, { frozen: frozen, identity: identity, children: _jsx(HistoryViewport, { rows: rows }) }) : _jsx(Text, { color: theme.colors.muted, children: "Start a conversation with the host agent." }), showHistoryHint && _jsx(Text, { dimColor: true, wrap: "truncate-end", children: dialogOpen ? 'Wheel/PgUp/PgDn · Scroll history' : historyWindow ? 'Earlier history · /latest returns to live conversation' : 'Scroll up or /older to load earlier history' })] });
|
|
14
|
+
}
|