@itookit/dsht 0.2.4 → 0.3.2

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.
Files changed (129) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +54 -25
  3. package/README.zh.md +54 -25
  4. package/dist/catalog/controller.d.ts +32 -0
  5. package/dist/catalog/controller.js +88 -0
  6. package/dist/catalog/index.d.ts +2 -0
  7. package/dist/catalog/index.js +2 -0
  8. package/dist/{cli.js → cli/index.js} +40 -24
  9. package/dist/controller/connection.d.ts +80 -0
  10. package/dist/controller/connection.js +190 -0
  11. package/dist/controller/controller.d.ts +260 -0
  12. package/dist/controller/controller.js +362 -0
  13. package/dist/controller/index.d.ts +5 -0
  14. package/dist/controller/index.js +3 -0
  15. package/dist/controller/memory-log.d.ts +35 -0
  16. package/dist/controller/memory-log.js +95 -0
  17. package/dist/cost/controller.d.ts +34 -0
  18. package/dist/cost/controller.js +107 -0
  19. package/dist/cost/index.d.ts +9 -0
  20. package/dist/cost/index.js +7 -0
  21. package/dist/cost/ledger-files.d.ts +20 -0
  22. package/dist/cost/ledger-files.js +128 -0
  23. package/dist/cost/ledger.d.ts +65 -0
  24. package/dist/cost/ledger.js +136 -0
  25. package/dist/cost/pricing.d.ts +48 -0
  26. package/dist/cost/pricing.js +141 -0
  27. package/dist/cost/records.d.ts +17 -0
  28. package/dist/cost/records.js +66 -0
  29. package/dist/cost/scanner.d.ts +22 -0
  30. package/dist/cost/scanner.js +95 -0
  31. package/dist/cost/types.d.ts +69 -0
  32. package/dist/cost/types.js +3 -0
  33. package/dist/index.d.ts +3 -0
  34. package/dist/index.js +2 -0
  35. package/dist/session/connection-view.d.ts +18 -0
  36. package/dist/session/connection-view.js +1 -0
  37. package/dist/{controller.d.ts → session/controller.d.ts} +119 -137
  38. package/dist/session/controller.js +616 -0
  39. package/dist/session/export-html.d.ts +9 -0
  40. package/dist/session/export-html.js +39 -0
  41. package/dist/session/export.d.ts +9 -0
  42. package/dist/session/export.js +38 -0
  43. package/dist/{history.d.ts → session/history.d.ts} +18 -1
  44. package/dist/session/history.js +338 -0
  45. package/dist/session/index.d.ts +17 -0
  46. package/dist/session/index.js +10 -0
  47. package/dist/session/markdown.d.ts +39 -0
  48. package/dist/session/markdown.js +255 -0
  49. package/dist/session/math.d.ts +11 -0
  50. package/dist/session/math.js +82 -0
  51. package/dist/{navigation.d.ts → session/navigation.d.ts} +1 -1
  52. package/dist/{navigation.js → session/navigation.js} +1 -1
  53. package/dist/{references.js → session/references.js} +1 -1
  54. package/dist/{telemetry.d.ts → session/telemetry.d.ts} +12 -1
  55. package/dist/{telemetry.js → session/telemetry.js} +18 -4
  56. package/dist/{transcript.d.ts → session/transcript.d.ts} +23 -1
  57. package/dist/{transcript.js → session/transcript.js} +36 -18
  58. package/dist/session/types.d.ts +18 -0
  59. package/dist/session/types.js +2 -0
  60. package/dist/state.d.ts +41 -0
  61. package/dist/state.js +9 -0
  62. package/dist/storage/directories.d.ts +14 -0
  63. package/dist/storage/directories.js +24 -0
  64. package/dist/storage/files.d.ts +43 -0
  65. package/dist/storage/files.js +135 -0
  66. package/dist/storage/index.d.ts +3 -0
  67. package/dist/storage/index.js +3 -0
  68. package/dist/transport/auth.js +65 -0
  69. package/dist/{client.d.ts → transport/client.d.ts} +8 -1
  70. package/dist/{client.js → transport/client.js} +21 -4
  71. package/dist/transport/host.d.ts +13 -0
  72. package/dist/transport/host.js +1 -0
  73. package/dist/ui/app.d.ts +11 -0
  74. package/dist/ui/app.js +726 -0
  75. package/dist/ui/chat/header.d.ts +11 -0
  76. package/dist/ui/chat/header.js +14 -0
  77. package/dist/{history-view.d.ts → ui/chat/history-view.d.ts} +1 -1
  78. package/dist/{history-view.js → ui/chat/history-view.js} +3 -3
  79. package/dist/ui/chat/status.d.ts +79 -0
  80. package/dist/ui/chat/status.js +311 -0
  81. package/dist/ui/chat/viewport.d.ts +17 -0
  82. package/dist/ui/chat/viewport.js +14 -0
  83. package/dist/ui/commands/parse.d.ts +96 -0
  84. package/dist/ui/commands/parse.js +121 -0
  85. package/dist/ui/commands/registry.d.ts +33 -0
  86. package/dist/ui/commands/registry.js +72 -0
  87. package/dist/ui/copy-mode.d.ts +4 -0
  88. package/dist/ui/copy-mode.js +6 -0
  89. package/dist/{cost-view.d.ts → ui/dialogs/cost.d.ts} +1 -1
  90. package/dist/{cost-view.js → ui/dialogs/cost.js} +3 -3
  91. package/dist/ui/dialogs/index.d.ts +120 -0
  92. package/dist/ui/dialogs/index.js +113 -0
  93. package/dist/ui/dialogs/picker.d.ts +18 -0
  94. package/dist/ui/dialogs/picker.js +38 -0
  95. package/dist/ui/frozen.d.ts +8 -0
  96. package/dist/ui/frozen.js +7 -0
  97. package/dist/ui/input/references.d.ts +12 -0
  98. package/dist/ui/input/references.js +15 -0
  99. package/dist/ui/mount.d.ts +6 -0
  100. package/dist/ui/mount.js +11 -0
  101. package/dist/{theme.d.ts → ui/theme/index.d.ts} +1 -1
  102. package/dsht-m.png +0 -0
  103. package/package.json +20 -14
  104. package/dist/app.d.ts +0 -21
  105. package/dist/app.js +0 -746
  106. package/dist/auth.js +0 -108
  107. package/dist/controller.js +0 -923
  108. package/dist/cost.d.ts +0 -119
  109. package/dist/cost.js +0 -313
  110. package/dist/history.js +0 -177
  111. package/dist/status.d.ts +0 -28
  112. package/dist/status.js +0 -157
  113. package/dsht.png +0 -0
  114. /package/dist/{cli.d.ts → cli/index.d.ts} +0 -0
  115. /package/dist/{memory.d.ts → session/memory.d.ts} +0 -0
  116. /package/dist/{memory.js → session/memory.js} +0 -0
  117. /package/dist/{references.d.ts → session/references.d.ts} +0 -0
  118. /package/dist/{auth.d.ts → transport/auth.d.ts} +0 -0
  119. /package/dist/{endpoint.d.ts → transport/endpoint.d.ts} +0 -0
  120. /package/dist/{endpoint.js → transport/endpoint.js} +0 -0
  121. /package/dist/{wire.d.ts → transport/wire.d.ts} +0 -0
  122. /package/dist/{wire.js → transport/wire.js} +0 -0
  123. /package/dist/{input-history.d.ts → ui/input/history.d.ts} +0 -0
  124. /package/dist/{input-history.js → ui/input/history.js} +0 -0
  125. /package/dist/{input.d.ts → ui/input/input.d.ts} +0 -0
  126. /package/dist/{input.js → ui/input/input.js} +0 -0
  127. /package/dist/{mouse.d.ts → ui/input/mouse.d.ts} +0 -0
  128. /package/dist/{mouse.js → ui/input/mouse.js} +0 -0
  129. /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
+ }
@@ -1,4 +1,4 @@
1
- import type { HistoryRow } from './history.ts';
1
+ import type { HistoryRow } from '../../session/history.ts';
2
2
  /** Render a viewport with role, reasoning, tool and result colors from the selected theme.
3
3
  * @param rows - Already wrapped, terminal-safe visible rows.
4
4
  * @returns Colored text with unchanged row geometry and plain-text role markers.
@@ -1,12 +1,12 @@
1
- import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  /** Only visible semantic rows become React nodes; ANSI colors are applied after remote text sanitation. */
3
3
  import { Text } from 'ink';
4
- import { useTheme } from "./theme.js";
4
+ import { useTheme } from "../theme/index.js";
5
5
  /** Render a viewport with role, reasoning, tool and result colors from the selected theme.
6
6
  * @param rows - Already wrapped, terminal-safe visible rows.
7
7
  * @returns Colored text with unchanged row geometry and plain-text role markers.
8
8
  */
9
9
  export function HistoryViewport({ rows }) {
10
10
  const theme = useTheme();
11
- return _jsx(Text, { children: rows.map((row, index) => _jsxs(Text, { color: theme.colors[row.kind], bold: row.bold, children: [row.text, index < rows.length - 1 ? '\n' : ''] }, index)) });
11
+ return _jsx(Text, { children: rows.map((row, index) => _jsxs(Text, { color: theme.colors[row.kind], bold: row.bold, children: [row.spans ? row.spans.map((span, index) => _jsx(Text, { bold: span.bold, italic: span.italic, underline: span.underline, strikethrough: span.strikethrough, inverse: span.inverse, children: span.text }, index)) : row.text, index < rows.length - 1 ? '\n' : ''] }, index)) });
12
12
  }
@@ -0,0 +1,79 @@
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, scope-labelled so it cannot read as a share of the day total. */
32
+ session?: StatusSegment;
33
+ /** Context share, labelled because a bare percentage next to money reads as a budget. */
34
+ context?: StatusSegment;
35
+ /** Today's cost across sessions; shown only where the width allows it. */
36
+ day?: 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
+ }
46
+ /** Elapsed time as `m:ss`, adding hours only when they exist.
47
+ * @param milliseconds - Duration to format.
48
+ * @returns Clock text without a leading zero on minutes.
49
+ */
50
+ export declare function clockText(milliseconds: number): string;
51
+ /** Age of a phase, in seconds while it is short and as a clock afterwards.
52
+ * @param milliseconds - Phase duration to format.
53
+ * @returns `28s` below a minute, otherwise `1:08`.
54
+ */
55
+ export declare function phaseText(milliseconds: number): string;
56
+ /** Fit the status groups into one row, or two when the state and the cost cannot share one.
57
+ *
58
+ * Groups are kept by value, not by column: the least valuable group is dropped first, so any width
59
+ * degrades continuously instead of snapping to a fixed layout. The cost is never dropped, only
60
+ * moved to the second row, because it is one of the few answers this bar exists to give.
61
+ * @param groups - Group texts, each already formatted for display.
62
+ * @param width - Available terminal columns.
63
+ * @returns One or two rows of segments; separators carry no colour of their own.
64
+ */
65
+ export declare function compactStatusRows(groups: StatusGroups, width: number): StatusSegment[][];
66
+ /** Render a live clock and selected-session metadata; the timer belongs to this mounted bar. */
67
+ export declare const StatusBar: import("react").NamedExoticComponent<{
68
+ controller: Controller;
69
+ expanded?: boolean;
70
+ width?: number;
71
+ revision?: number;
72
+ scroll?: number;
73
+ pageSize?: number;
74
+ onScroll?(next: number): void;
75
+ onOverflow?(overflow: boolean): void;
76
+ onRows?(rows: number): void;
77
+ /** Why the display is paused, so a frozen clock can say so instead of looking stalled. */
78
+ pauseReason?: "copy" | "dialog" | "history";
79
+ }>;
@@ -0,0 +1,311 @@
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
+ /** Elapsed time as `m:ss`, adding hours only when they exist.
55
+ * @param milliseconds - Duration to format.
56
+ * @returns Clock text without a leading zero on minutes.
57
+ */
58
+ export function clockText(milliseconds) {
59
+ const seconds = Math.max(0, Math.floor(milliseconds / 1000));
60
+ const minutes = Math.floor(seconds / 60);
61
+ if (minutes < 60)
62
+ return `${minutes}:${String(seconds % 60).padStart(2, '0')}`;
63
+ return `${Math.floor(minutes / 60)}:${String(minutes % 60).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`;
64
+ }
65
+ /** Age of a phase, in seconds while it is short and as a clock afterwards.
66
+ * @param milliseconds - Phase duration to format.
67
+ * @returns `28s` below a minute, otherwise `1:08`.
68
+ */
69
+ export function phaseText(milliseconds) {
70
+ const seconds = Math.max(0, Math.floor(milliseconds / 1000));
71
+ return seconds < 60 ? `${seconds}s` : clockText(milliseconds);
72
+ }
73
+ /** Fit the status groups into one row, or two when the state and the cost cannot share one.
74
+ *
75
+ * Groups are kept by value, not by column: the least valuable group is dropped first, so any width
76
+ * degrades continuously instead of snapping to a fixed layout. The cost is never dropped, only
77
+ * moved to the second row, because it is one of the few answers this bar exists to give.
78
+ * @param groups - Group texts, each already formatted for display.
79
+ * @param width - Available terminal columns.
80
+ * @returns One or two rows of segments; separators carry no colour of their own.
81
+ */
82
+ export function compactStatusRows(groups, width) {
83
+ if (width <= 0)
84
+ return [];
85
+ // Remote text reaches this bar, so every group is stripped of control characters before packing.
86
+ const clean = (group) => group === undefined ? undefined : { ...group, text: safeText(group.text).replace(/[\r\n\t]+/g, ' ') };
87
+ groups = { state: clean(groups.state), phase: clean(groups.phase), stop: clean(groups.stop), session: clean(groups.session),
88
+ context: clean(groups.context), day: clean(groups.day), model: clean(groups.model), effort: clean(groups.effort),
89
+ turns: clean(groups.turns), tokens: clean(groups.tokens) };
90
+ const cluster = [groups.state, groups.phase, groups.stop].filter(Boolean);
91
+ const rest = [groups.session, groups.context, groups.day, groups.model, groups.effort, groups.turns, groups.tokens].filter(Boolean);
92
+ // One row while enough of the sequence fits; each step drops the least valuable group first.
93
+ // The cost is never dropped, only moved to the second row, so the search stops above it.
94
+ const floor = groups.session === undefined ? 0 : 1;
95
+ for (let keep = rest.length; keep >= floor; keep--) {
96
+ const row = pack(cluster, rest.slice(0, keep));
97
+ if (measure(row) <= width)
98
+ return [row];
99
+ }
100
+ if (groups.session === undefined && measure(pack(cluster, [])) > width)
101
+ return [fitCluster(cluster, width)];
102
+ // Not even the state cluster and the cost share a row, so the cost opens the second one.
103
+ return [fitCluster(cluster, width), packGreedy(rest, width)];
104
+ }
105
+ /** Fit the state cluster, dropping the phase and then the stop hint before truncating the state.
106
+ * @param cluster - State, phase and stop hint in that order.
107
+ * @param width - Available terminal columns.
108
+ * @returns The cluster as far as it fits.
109
+ */
110
+ function fitCluster(cluster, width) {
111
+ for (let keep = cluster.length; keep >= 1; keep--) {
112
+ const row = pack(cluster.slice(0, keep), []);
113
+ if (measure(row) <= width)
114
+ return row;
115
+ }
116
+ const [first] = cluster;
117
+ return first === undefined ? [] : [{ ...first, text: toolLine(first.text, width) }];
118
+ }
119
+ /** Join the state cluster and the groups that follow it with their boundary separator.
120
+ * @param cluster - State, phase and stop hint, separated by middots.
121
+ * @param rest - Remaining groups, separated from the cluster by a bar.
122
+ * @returns The packed row.
123
+ */
124
+ function pack(cluster, rest) {
125
+ return [...cluster, ...rest].flatMap((group, index) => index === 0 ? [group]
126
+ : [{ text: index === cluster.length ? ' │ ' : ' · ' }, group]);
127
+ }
128
+ /** Add leading groups while they fit, stopping at the first that does not.
129
+ * @param groups - Groups in keep order.
130
+ * @param width - Available terminal columns.
131
+ * @returns The groups that fit.
132
+ */
133
+ function packGreedy(groups, width) {
134
+ let row = [];
135
+ for (const group of groups) {
136
+ const next = row.length === 0 ? [group] : [...row, { text: ' · ' }, group];
137
+ if (measure(next) > width)
138
+ break;
139
+ row = next;
140
+ }
141
+ return row;
142
+ }
143
+ /** Join groups with a separator, keeping the separator uncoloured.
144
+ * @param groups - Groups to join.
145
+ * @param separator - Separator text between them.
146
+ * @returns The groups with separators interleaved.
147
+ */
148
+ function joinSegments(groups, separator) {
149
+ return groups.flatMap((group, index) => index === 0 ? [group] : [{ text: separator }, group]);
150
+ }
151
+ /** Display width of one packed row, so wide characters count as two columns.
152
+ * @param row - Segments already joined with their separators.
153
+ * @returns Columns the row occupies.
154
+ */
155
+ function measure(row) {
156
+ return row.reduce((sum, segment) => sum + stringWidth(segment.text), 0);
157
+ }
158
+ /** Render a live clock and selected-session metadata; the timer belongs to this mounted bar. */
159
+ export const StatusBar = memo(function StatusBar({ controller, expanded = false, width, scroll = 0, pageSize, onScroll, onOverflow, onRows, pauseReason }) {
160
+ const paused = pauseReason !== undefined;
161
+ const theme = useTheme();
162
+ const { stdout } = useStdout();
163
+ const [now, setNow] = useState(Date.now);
164
+ const [reported, setReported] = useState(1);
165
+ const running = controller.running;
166
+ const since = controller.workingSince;
167
+ useEffect(() => {
168
+ setNow(Date.now());
169
+ if (!running || paused)
170
+ return;
171
+ const timer = setInterval(() => setNow(Date.now()), 1000);
172
+ return () => clearInterval(timer);
173
+ }, [running, since, paused]);
174
+ const state = controller.state;
175
+ const workspace = state.workspaces.find(item => item.workspaceId === state.workspaceId);
176
+ const view = controller.telemetry.view(state.sessionId);
177
+ const costs = controller.costs;
178
+ const sessionCost = costs?.hasSession(state.sessionId) ? costText(costs.total(state.sessionId)) : '?';
179
+ const todayCost = costs ? costText(costs.total(undefined, 1, Date.now())) : '?';
180
+ // `*` belongs to costText alone; incomplete coverage is a separate degradation, reported by `!`.
181
+ const coverage = costs?.coverage ?? 'complete';
182
+ const label = workspace ? `${workspace.title} · ${workspace.path}` : 'none selected';
183
+ if (!expanded) {
184
+ const selection = record(view.values.modelSelection);
185
+ const route = record(running ? selection.lastUsed ?? selection.next ?? state.defaultModel : selection.next ?? selection.lastUsed ?? state.defaultModel);
186
+ const model = typeof route.model === 'string' ? route.model.replace(/^deepseek-/, '') : undefined;
187
+ const effort = typeof route.reasoningEffort === 'string' ? route.reasoningEffort : undefined;
188
+ const pressure = record(view.values.contextPressure);
189
+ const used = numeric(pressure.projectedTokens) ?? numeric(pressure.pressureTokens);
190
+ const capacity = numeric(pressure.contextWindow);
191
+ const percent = used !== undefined && capacity !== undefined && capacity > 0 ? Math.min(100, Math.round(used / capacity * 100)) : undefined;
192
+ const usage = record(view.values.tokenUsage);
193
+ const buckets = [usage.uncachedInputTokens, usage.outputTokens, usage.cacheReadTokens, usage.cacheWriteTokens].map(numeric);
194
+ const total = buckets.every(value => value !== undefined) ? buckets.reduce((a, b) => a + b, 0) : undefined;
195
+ const compactCount = (value) => value === undefined ? '?' : compactNumber.format(value);
196
+ const phase = state.transcript.livePhase;
197
+ const clock = running && since !== undefined ? ` ${clockText(now - since)}` : '';
198
+ const phaseLabel = phase === undefined ? undefined
199
+ : phase.kind === 'tool' ? `${phase.name ?? 'tool'} ${phaseText(now - phase.startedAt)}`
200
+ : `${phase.kind === 'thinking' ? 'think' : 'write'} ${phaseText(now - phase.startedAt)}`;
201
+ // The state token reports a fact and never guesses: a paused clock is named, offline and errors
202
+ // take the token over, and an unknown phase simply leaves the phase group empty.
203
+ const stateToken = !state.online
204
+ ? { text: '! Offline', color: theme.status.offline }
205
+ : state.controlError || state.modelError
206
+ ? { text: '⚠ Error', color: theme.status.warning }
207
+ : pauseReason !== undefined
208
+ ? { text: `⏸ ${pauseReason}${clock}`, color: theme.colors.muted }
209
+ : running ? { text: `◐${clock}`, color: theme.status.working } : { text: '● Ready', color: theme.status.ready };
210
+ // The marker names the scope it belongs to: a subtotal is inexact when a record could not be
211
+ // priced, when it is only estimated, or when the scan has not covered every session yet.
212
+ const inexact = coverage !== 'complete';
213
+ const money = (value) => `¥${value.amount.toFixed(2)}${value.unknown || value.estimated || inexact ? '*' : ''}`;
214
+ const sessionTotal = costs !== undefined && costs.hasSession(state.sessionId) ? costs.total(state.sessionId) : undefined;
215
+ const groups = {
216
+ state: stateToken,
217
+ ...(phaseLabel === undefined || pauseReason !== undefined ? {} : { phase: { text: phaseLabel } }),
218
+ ...(running && pauseReason === undefined ? { stop: { text: '^C' } } : {}),
219
+ ...(sessionTotal === undefined ? {} : { session: { text: `S${money(sessionTotal)}`, color: theme.status.cost } }),
220
+ ...(percent === undefined ? {} : { context: { text: `ctx ${percent}%`,
221
+ color: percent >= 95 ? theme.status.critical : percent >= 80 ? theme.status.warning : theme.status.context } }),
222
+ ...(costs === undefined ? {} : { day: { text: `D${money(costs.total(undefined, 1, Date.now()))}`, color: theme.status.cost } }),
223
+ ...(model === undefined ? {} : { model: { text: model, color: theme.status.model } }),
224
+ ...(model === undefined || effort === undefined ? {} : { effort: { text: effort, color: theme.status.model } }),
225
+ ...(numeric(record(view.values.sessionStats).turns) === undefined ? {} : { turns: { text: `${count(numeric(record(view.values.sessionStats).turns))} turns`, color: theme.status.usage } }),
226
+ ...(total === undefined ? {} : { tokens: { text: `${compactCount(total)} tok`, color: theme.status.usage } }),
227
+ };
228
+ const rows = compactStatusRows(groups, width ?? Math.max(1, (stdout.columns ?? 80) - 2));
229
+ if (rows.length !== reported) {
230
+ setReported(rows.length);
231
+ onRows?.(rows.length);
232
+ }
233
+ 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)) });
234
+ }
235
+ return _jsx(StatusDetails, { controller: controller, theme: theme, width: width, now: now, scroll: scroll, pageSize: pageSize, onScroll: onScroll, onOverflow: onOverflow });
236
+ });
237
+ /** Expanded detail panel.
238
+ *
239
+ * It is a separate component because it is the only branch that scrolls, and a hook behind the
240
+ * collapsed branch's early return would change the hook order between the two states.
241
+ */
242
+ const StatusDetails = memo(function StatusDetails({ controller, theme, width, now, scroll, pageSize, onScroll, onOverflow }) {
243
+ const { stdout } = useStdout();
244
+ const state = controller.state;
245
+ const running = controller.running;
246
+ const since = controller.workingSince;
247
+ const workspace = state.workspaces.find(item => item.workspaceId === state.workspaceId);
248
+ const view = controller.telemetry.view(state.sessionId);
249
+ const costs = controller.costs;
250
+ const sessionCost = costs?.hasSession(state.sessionId) ? costText(costs.total(state.sessionId)) : '?';
251
+ const todayCost = costs ? costText(costs.total(undefined, 1, Date.now())) : '?';
252
+ // `*` belongs to costText alone; incomplete coverage is a separate degradation, reported by `!`.
253
+ const coverage = costs?.coverage ?? 'complete';
254
+ const label = workspace ? `${workspace.title} · ${workspace.path}` : 'none selected';
255
+ // Every detail row wraps to the terminal width, so a narrow terminal loses nothing; lines that
256
+ // still do not fit are scrolled rather than dropped, because the panel shares the screen height.
257
+ // Rows are merged and labelled compactly so a normal terminal shows every detail on one screen;
258
+ // the wrap and the scroll offset remain the fallback for a short or very narrow terminal.
259
+ const turns = count(numeric(record(view.values.sessionStats).turns));
260
+ const duration = since === undefined ? 'unknown duration' : elapsedTime(now - since);
261
+ const detail = [
262
+ { key: 'activity', color: running ? theme.colors.context : theme.colors.muted, text: running
263
+ ? `◐ Working · ${duration}${state.transcript.activeTurnStartedAt === undefined ? ' (observed)' : ''} · Ctrl+C Stop`
264
+ : '● Ready · Ctrl+C exit' },
265
+ { key: 'host', text: `${safeText(controller.base)} · ${safeText(state.status)}${!state.online ? ' · offline, last known status' : ''}` },
266
+ ...state.sessionId
267
+ ? [{ key: 'session', text: `Session ${safeText(state.sessionId)}${controller.sessionMode ? ` · ${safeText(controller.sessionMode)}` : ''}` }] : [],
268
+ { key: 'workspace', text: `Workspace ${safeText(label)}` },
269
+ ...metricLines(view.values, state.defaultModel, running).map((line, index) => ({ key: `metric-${index}`, text: safeText(line), dim: true })),
270
+ ...costs ? [{ key: 'cost', text: `Cost ${sessionCost} session · ${todayCost} today · ${turns} turns`, dim: true }] : [],
271
+ { key: 'queued', text: `Queued ${count(view.queued)} · Jobs ${count(view.jobs)}${costs ? '' : ` · ${turns} turns`}`, dim: true },
272
+ ...costs && coverage === 'partial'
273
+ ? [{ key: 'coverage', color: theme.colors.context, text: `Cost coverage incomplete: ${costs.error ? safeText(costs.error) : 'no complete scan yet'}` }] : [],
274
+ ...state.controlError ? [{ key: 'control-error', color: theme.colors.context, text: safeText(state.controlError) }] : [],
275
+ ...state.presetError ? [{ key: 'preset-error', color: theme.colors.context, text: `Preset names unavailable: ${safeText(state.presetError)}` }] : [],
276
+ ...state.modelError ? [{ key: 'model-error', color: theme.colors.context, text: `Model catalog unavailable: ${safeText(state.modelError)}` }] : [],
277
+ ];
278
+ // Border and horizontal padding take four columns, so a detail row wraps inside what is left.
279
+ const inner = Math.max(1, (width ?? Math.max(3, (stdout.columns ?? 80) - 2)) - 4);
280
+ const measured = (text) => wrapAnsi(text, inner, { trim: false, hard: true }).split('\n').length;
281
+ const lines = detail.flatMap(row => wrapAnsi(row.text, inner, { trim: false, hard: true }).split('\n')
282
+ .map((text, index) => ({ ...row, key: `${row.key}:${index}`, text })));
283
+ const budget = Math.max(1, pageSize ?? lines.length);
284
+ const hint = (first, last, total) => `Status ${first}-${last}/${total} · ↑↓ scroll · Esc close`;
285
+ // A footer that wraps takes rows from the view, so its height is reserved before sizing it.
286
+ const size = Math.max(1, budget - (lines.length > budget ? measured(hint(0, 0, lines.length)) : 0));
287
+ const start = Math.max(0, Math.min(scroll, Math.max(0, lines.length - size)));
288
+ // Arrows and the wheel can ask for a line past either end; report the settled position back.
289
+ useEffect(() => { if (start !== scroll)
290
+ onScroll?.(start); }, [start, scroll, onScroll]);
291
+ // The caller needs to know whether this panel owns the arrows or has nothing to scroll.
292
+ useEffect(() => { onOverflow?.(lines.length > size); }, [lines.length, size, onOverflow]);
293
+ const visible = lines.slice(start, start + size);
294
+ 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) })] });
295
+ });
296
+ function record(value) {
297
+ return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : {};
298
+ }
299
+ function numeric(value) {
300
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined;
301
+ }
302
+ function count(value) { return value === undefined ? '?' : value.toLocaleString('en-US'); }
303
+ function modelName(value) {
304
+ const model = record(value);
305
+ if (typeof model.model !== 'string' || typeof model.provider !== 'string')
306
+ return 'unknown';
307
+ return safeText(`${model.provider}/${model.model}${typeof model.reasoningEffort === 'string' ? ` (${model.reasoningEffort})` : ''}`);
308
+ }
309
+ function compactCost(total) {
310
+ return `~¥${total.amount.toFixed(2)}${total.unknown || total.estimated ? '*' : ''}`;
311
+ }
@@ -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
+ }
@@ -0,0 +1,96 @@
1
+ import type { State } from '../../state.ts';
2
+ /** One classified submission; every side effect stays with the caller. */
3
+ export type Submission = {
4
+ kind: 'ignore';
5
+ } | {
6
+ kind: 'reference';
7
+ } | {
8
+ kind: 'copy';
9
+ } | {
10
+ kind: 'quit';
11
+ } | {
12
+ kind: 'panel';
13
+ panel: 'cost' | 'status' | 'help';
14
+ } | {
15
+ kind: 'remove';
16
+ target: 'workspace' | 'session';
17
+ query: string;
18
+ } | {
19
+ kind: 'navigate';
20
+ target: 'workspace' | 'session';
21
+ query?: string;
22
+ } | {
23
+ kind: 'path';
24
+ value: string;
25
+ } | {
26
+ kind: 'latest';
27
+ } | {
28
+ kind: 'models';
29
+ args: string[];
30
+ } | {
31
+ kind: 'queue';
32
+ } | {
33
+ kind: 'newSession';
34
+ } | {
35
+ kind: 'history';
36
+ query: string;
37
+ } | {
38
+ kind: 'sessionSearch';
39
+ command: '/ssearch' | '/wsearch';
40
+ query: string;
41
+ } | {
42
+ kind: 'historySearch';
43
+ query: string;
44
+ } | {
45
+ kind: 'think';
46
+ target: string;
47
+ } | {
48
+ kind: 'older';
49
+ } | {
50
+ kind: 'compact';
51
+ } | {
52
+ kind: 'cancel';
53
+ } | {
54
+ kind: 'approval';
55
+ allowed: boolean;
56
+ } | {
57
+ kind: 'hostCommand';
58
+ line: string;
59
+ } | {
60
+ kind: 'export';
61
+ destination?: string;
62
+ } | {
63
+ kind: 'exportHtml';
64
+ destination?: string;
65
+ } | {
66
+ kind: 'answer';
67
+ text: string;
68
+ } | {
69
+ kind: 'error';
70
+ message: string;
71
+ } | {
72
+ kind: 'prompt';
73
+ text: string;
74
+ };
75
+ /** UI facts the classification reads; everything else stays with the caller. */
76
+ export interface SubmissionContext {
77
+ /** Completion menu owns the draft while open. */
78
+ referenceOpen: boolean;
79
+ /** Copy mode ignores submissions entirely. */
80
+ copyMode: boolean;
81
+ /** A question or approval of the selected session is pending. */
82
+ pending: boolean;
83
+ /** The pending interaction is a question. */
84
+ question: boolean;
85
+ screen: State['screen'];
86
+ }
87
+ /** Classify one composer draft without performing any of its effects.
88
+ *
89
+ * The order of the checks is the command precedence: reference completion, copy mode, the panels
90
+ * that toggle in place, navigation and removal, then the session and host commands, then answers,
91
+ * then a plain prompt.
92
+ * @param raw - Draft exactly as submitted.
93
+ * @param context - Current UI facts.
94
+ * @returns The action to perform.
95
+ */
96
+ export declare function classifySubmission(raw: string, context: SubmissionContext): Submission;