@itookit/dsht 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/dist/cost.d.ts ADDED
@@ -0,0 +1,97 @@
1
+ import { type ObjectValue } from './wire.ts';
2
+ interface Rates {
3
+ input: number;
4
+ cacheRead: number;
5
+ cacheWrite: number;
6
+ output: number;
7
+ }
8
+ /** An explicit validity interval and weekday peak windows in the named time zone. */
9
+ export interface PriceVersion {
10
+ id: string;
11
+ provider: string;
12
+ model: string;
13
+ from: string;
14
+ until?: string;
15
+ currency: 'CNY';
16
+ source: string;
17
+ timezone: string;
18
+ peak: Rates;
19
+ offPeak: Rates;
20
+ weekdays: number[];
21
+ windows: [number, number][];
22
+ }
23
+ /** Published rates verified on 2026-09-10; preceding dates require historical configuration. */
24
+ export declare const DEFAULT_PRICES: PriceVersion[];
25
+ /** Validate user-maintained price versions, rejecting ambiguous overlapping intervals.
26
+ * @param value - Parsed prices.json array.
27
+ * @returns Price versions with validated rates and schedules.
28
+ */
29
+ export declare function pricesFrom(value: unknown): PriceVersion[];
30
+ /** Return the calendar date used by both daily and three-calendar-day summaries.
31
+ * @param time - Epoch milliseconds.
32
+ * @returns Beijing calendar date, YYYY-MM-DD.
33
+ */
34
+ export declare function costDay(time: number): string;
35
+ /** Summary always retains the number of unpriced records alongside the known subtotal. */
36
+ export interface CostTotal {
37
+ amount: number;
38
+ unknown: number;
39
+ records: number;
40
+ }
41
+ /** Select a price by event time, applying half-open local peak windows.
42
+ * @param prices - Validated versions.
43
+ * @param provider - Provider identity from the recorded request.
44
+ * @param model - Recorded model name; official DeepSeek aliases fall back to Pro when containing pro, otherwise Flash.
45
+ * @param time - Recorded settlement timestamp used as a billing-time estimate.
46
+ * @returns Matching price version and per-million-token rates, if known.
47
+ */
48
+ export declare function priceAt(prices: PriceVersion[], provider: string, model: string, time: number): {
49
+ price: PriceVersion;
50
+ rates: Rates;
51
+ } | undefined;
52
+ /** Keep only billing-relevant fields; prompts, tool bodies, cookies and keys never enter the ledger.
53
+ * @param records - One HTTP history page's records.
54
+ * @returns Minimal durable events for a deterministic usage fold.
55
+ */
56
+ export declare function costRecords(records: unknown): ObjectValue[];
57
+ /** Per-origin cache of priced request settlements; each scan replaces a session at a fixed cut. */
58
+ export declare class CostLedger {
59
+ readonly prices: PriceVersion[];
60
+ readonly directory?: string | undefined;
61
+ private sessions;
62
+ private totals;
63
+ scannedAt?: number;
64
+ scanning: boolean;
65
+ error: string;
66
+ constructor(prices?: PriceVersion[], directory?: string | undefined);
67
+ /** Load immutable cut files, keeping the newest complete scan for each session. */
68
+ load(): Promise<void>;
69
+ /** Replace one session using all billing events through the opening snapshot cut.
70
+ * @param sessionId - Host session identity.
71
+ * @param cut - Opening cursor, preventing a stale scan from overwriting a newer scan.
72
+ * @param events - Minimal events returned by costRecords, across all history pages.
73
+ */
74
+ replace(sessionId: string, cut: number, events: ObjectValue[]): Promise<void>;
75
+ /** Whether this session has a complete cached scan.
76
+ * @param sessionId - Selected session identity.
77
+ * @returns True when a complete scan is available.
78
+ */
79
+ hasSession(sessionId?: string): boolean;
80
+ /** Describe unpriced model/usage combinations without exposing conversation content.
81
+ * @returns Unique reasons across cached sessions.
82
+ */
83
+ missing(): string[];
84
+ /** Summarize cached requests across one session or Beijing calendar days.
85
+ * @param sessionId - Optional session restriction.
86
+ * @param days - Today or today plus the preceding two calendar days.
87
+ * @param now - Clock used for date attribution.
88
+ * @returns Known subtotal and unpriced count; undated records are unpriced in every date range.
89
+ */
90
+ total(sessionId?: string, days?: 1 | 3, now?: number): CostTotal;
91
+ }
92
+ /** Compact estimates retain an asterisk whenever a subtotal contains unpriced records.
93
+ * @param total - Summary from the ledger.
94
+ * @returns Yuan amount and incompleteness marker.
95
+ */
96
+ export declare function costText(total: CostTotal): string;
97
+ export {};
package/dist/cost.js ADDED
@@ -0,0 +1,267 @@
1
+ /** Versioned CNY estimates from durable request usage; provider invoices remain authoritative. */
2
+ import { createHash, randomUUID } from 'node:crypto';
3
+ import { mkdir, readFile, readdir, rename, writeFile, unlink } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+ import { object, array } from "./wire.js";
6
+ const clocks = new Map();
7
+ /** Published rates verified on 2026-09-10; preceding dates require historical configuration. */
8
+ export const DEFAULT_PRICES = ['deepseek-v4-flash', 'deepseek-v4-pro', 'deepseek-v4-flash-vision-exp'].map(model => {
9
+ const scale = model === 'deepseek-v4-pro' ? 3 : 1;
10
+ return { id: `deepseek-2026-09-10-${model}`, provider: 'deepseek-official', model,
11
+ from: '2026-09-10T00:00:00+08:00', currency: 'CNY', source: 'https://api-docs.deepseek.com/zh-cn/quick_start/pricing/', timezone: 'Asia/Shanghai',
12
+ peak: { input: 3 * scale, cacheRead: 0.1 * scale, cacheWrite: 3 * scale, output: 9 * scale },
13
+ offPeak: { input: 1.5 * scale, cacheRead: 0.05 * scale, cacheWrite: 1.5 * scale, output: 4.5 * scale },
14
+ weekdays: [1, 2, 3, 4, 5], windows: [[540, 720], [840, 1080]] };
15
+ });
16
+ /** Validate user-maintained price versions, rejecting ambiguous overlapping intervals.
17
+ * @param value - Parsed prices.json array.
18
+ * @returns Price versions with validated rates and schedules.
19
+ */
20
+ export function pricesFrom(value) {
21
+ if (!Array.isArray(value))
22
+ throw new Error('prices.json must contain an array');
23
+ const ids = new Set();
24
+ for (const raw of value) {
25
+ const p = object(raw);
26
+ for (const key of ['id', 'provider', 'model', 'source', 'timezone', 'from'])
27
+ if (typeof p[key] !== 'string' || !p[key])
28
+ throw new Error(`Invalid price ${key}`);
29
+ if (ids.has(String(p.id)))
30
+ throw new Error('Duplicate price id');
31
+ ids.add(String(p.id));
32
+ const from = Date.parse(String(p.from));
33
+ const until = p.until === undefined ? Infinity : Date.parse(String(p.until));
34
+ if (!Number.isFinite(from) || !(until > from) || p.currency !== 'CNY')
35
+ throw new Error('Invalid price interval or currency');
36
+ new Intl.DateTimeFormat('en', { timeZone: String(p.timezone) }).format();
37
+ for (const key of ['peak', 'offPeak'])
38
+ for (const bucket of ['input', 'cacheRead', 'cacheWrite', 'output']) {
39
+ const rate = object(p[key])[bucket];
40
+ if (typeof rate !== 'number' || !Number.isFinite(rate) || rate < 0)
41
+ throw new Error('Invalid token rate');
42
+ }
43
+ if (!Array.isArray(p.weekdays) || p.weekdays.some(d => typeof d !== 'number' || !Number.isInteger(d) || d < 0 || d > 6)
44
+ || !Array.isArray(p.windows) || p.windows.some(w => !Array.isArray(w) || w.length !== 2 || w.some(n => typeof n !== 'number' || !Number.isInteger(n)) || Number(w[0]) < 0 || Number(w[1]) > 1440 || Number(w[0]) >= Number(w[1])))
45
+ throw new Error('Invalid peak schedule');
46
+ }
47
+ const prices = value;
48
+ for (const [index, p] of prices.entries())
49
+ for (const q of prices.slice(index + 1)) {
50
+ if (p.provider === q.provider && p.model === q.model && Date.parse(p.from) < (q.until ? Date.parse(q.until) : Infinity)
51
+ && Date.parse(q.from) < (p.until ? Date.parse(p.until) : Infinity))
52
+ throw new Error('Overlapping price intervals');
53
+ }
54
+ return prices;
55
+ }
56
+ /** Return the calendar date used by both daily and three-calendar-day summaries.
57
+ * @param time - Epoch milliseconds.
58
+ * @returns Beijing calendar date, YYYY-MM-DD.
59
+ */
60
+ export function costDay(time) { return new Date(time + 8 * 3600_000).toISOString().slice(0, 10); }
61
+ /** Select a price by event time, applying half-open local peak windows.
62
+ * @param prices - Validated versions.
63
+ * @param provider - Provider identity from the recorded request.
64
+ * @param model - Recorded model name; official DeepSeek aliases fall back to Pro when containing pro, otherwise Flash.
65
+ * @param time - Recorded settlement timestamp used as a billing-time estimate.
66
+ * @returns Matching price version and per-million-token rates, if known.
67
+ */
68
+ export function priceAt(prices, provider, model, time) {
69
+ const active = (p) => p.provider === provider && Date.parse(p.from) <= time && (p.until === undefined || time < Date.parse(p.until));
70
+ const family = model.toLowerCase().includes('pro') ? 'deepseek-v4-pro' : 'deepseek-v4-flash';
71
+ const price = prices.find(p => active(p) && p.model === model)
72
+ ?? (provider === 'deepseek-official' ? prices.find(p => active(p) && p.model === family) : undefined);
73
+ if (!price)
74
+ return;
75
+ let clock = clocks.get(price.timezone);
76
+ if (!clock) {
77
+ clock = new Intl.DateTimeFormat('en-US', { timeZone: price.timezone, weekday: 'short', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' });
78
+ clocks.set(price.timezone, clock);
79
+ }
80
+ const parts = clock.formatToParts(time);
81
+ const part = (name) => parts.find(p => p.type === name).value;
82
+ const day = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].indexOf(part('weekday'));
83
+ const minute = Number(part('hour')) * 60 + Number(part('minute'));
84
+ return { price, rates: price.weekdays.includes(day) && price.windows.some(([a, b]) => minute >= a && minute < b) ? price.peak : price.offPeak };
85
+ }
86
+ /** Keep only billing-relevant fields; prompts, tool bodies, cookies and keys never enter the ledger.
87
+ * @param records - One HTTP history page's records.
88
+ * @returns Minimal durable events for a deterministic usage fold.
89
+ */
90
+ export function costRecords(records) {
91
+ return array(records).map(raw => object(object(raw).event)).filter(e => ['request/context', 'assistant/message', 'assistant/attempt', 'llm/retry-started', 'session/end-seed'].includes(String(e.type))).map(e => {
92
+ const d = object(e.data);
93
+ const m = object(d.message ?? {});
94
+ const stream = array(d.stream ?? []).map(r => object(object(r).chunk ?? {})).filter(c => c.type === 'usage');
95
+ return { seq: e.seq ?? null, time: e.time ?? null, type: e.type, data: {
96
+ inherited: d.inherited ?? false, turn: d.turn ?? null, step: d.step ?? null, provider: d.provider ?? null, model: d.model ?? null,
97
+ source: m.source ?? null, usage: d.usage ?? stream.at(-1)?.usage ?? null,
98
+ } };
99
+ });
100
+ }
101
+ /** Per-origin cache of priced request settlements; each scan replaces a session at a fixed cut. */
102
+ export class CostLedger {
103
+ prices;
104
+ directory;
105
+ sessions = new Map();
106
+ totals = new Map();
107
+ scannedAt;
108
+ scanning = false;
109
+ error = '';
110
+ constructor(prices = DEFAULT_PRICES, directory) {
111
+ this.prices = prices;
112
+ this.directory = directory;
113
+ }
114
+ /** Load immutable cut files, keeping the newest complete scan for each session. */
115
+ async load() {
116
+ if (!this.directory)
117
+ return;
118
+ this.totals.clear();
119
+ await mkdir(this.directory, { recursive: true, mode: 0o700 });
120
+ for (const name of await readdir(this.directory)) {
121
+ if (!name.endsWith('.json'))
122
+ continue;
123
+ let raw;
124
+ try {
125
+ raw = await readFile(join(this.directory, name), 'utf8');
126
+ }
127
+ catch (error) {
128
+ if (error instanceof Error && 'code' in error && error.code === 'ENOENT')
129
+ continue;
130
+ throw error;
131
+ }
132
+ const v = JSON.parse(raw);
133
+ if (v.version !== 1 || typeof v.sessionId !== 'string' || !Number.isSafeInteger(v.cut) || !Array.isArray(v.charges)
134
+ || v.charges.some(c => !c || typeof c.key !== 'string' || typeof c.provider !== 'string' || typeof c.model !== 'string'
135
+ || (c.amount !== undefined && (typeof c.amount !== 'number' || !Number.isFinite(c.amount) || c.amount < 0))
136
+ || (c.time !== undefined && (typeof c.time !== 'number' || !Number.isFinite(c.time) || c.time < 0 || c.time > 8.64e15))
137
+ || (c.usage !== undefined && Object.values(c.usage).some(n => typeof n !== 'number' || !Number.isSafeInteger(n) || n < 0))))
138
+ throw new Error('Invalid cost ledger');
139
+ for (const c of v.charges)
140
+ if (c.price)
141
+ pricesFrom([c.price]);
142
+ if ((this.sessions.get(v.sessionId)?.cut ?? -2) <= v.cut)
143
+ this.sessions.set(v.sessionId, v);
144
+ }
145
+ }
146
+ /** Replace one session using all billing events through the opening snapshot cut.
147
+ * @param sessionId - Host session identity.
148
+ * @param cut - Opening cursor, preventing a stale scan from overwriting a newer scan.
149
+ * @param events - Minimal events returned by costRecords, across all history pages.
150
+ */
151
+ async replace(sessionId, cut, events) {
152
+ if ((this.sessions.get(sessionId)?.cut ?? -2) > cut)
153
+ return;
154
+ const old = new Map(this.sessions.get(sessionId)?.charges.map(c => [c.key, c]));
155
+ const charges = [];
156
+ const inheritedCut = Math.max(-1, ...events.filter(e => e.type === 'session/end-seed' && object(e.data).inherited === true).map(e => Number(e.seq)));
157
+ let route = {};
158
+ let last;
159
+ for (const e of [...new Map(events.map(e => [Number(e.seq), e])).values()].sort((a, b) => Number(a.seq) - Number(b.seq))) {
160
+ const d = object(e.data);
161
+ if (e.type === 'request/context') {
162
+ route = d;
163
+ continue;
164
+ }
165
+ if (Number(e.seq) <= inheritedCut || e.type === 'session/end-seed')
166
+ continue;
167
+ if (e.type === 'llm/retry-started') {
168
+ if (last?.turn === d.turn && last?.step === d.step)
169
+ last = undefined;
170
+ continue;
171
+ }
172
+ const source = object(d.source ?? {});
173
+ const provider = String(source.provider ?? route.provider ?? '');
174
+ const model = String(source.model ?? route.model ?? '');
175
+ const time = typeof e.time === 'number' && Number.isFinite(e.time) && e.time >= 0 && e.time <= 8.64e15 ? e.time : undefined;
176
+ const u = object(d.usage ?? {});
177
+ const buckets = [u.inputTokens, u.outputTokens, u.cacheReadTokens ?? 0, u.cacheWriteTokens ?? 0];
178
+ const valid = buckets.every(n => typeof n === 'number' && Number.isSafeInteger(n) && n >= 0)
179
+ && (u.totalTokens === undefined || typeof u.totalTokens === 'number' && Number.isSafeInteger(u.totalTokens) && u.totalTokens === buckets.reduce((sum, n) => sum + Number(n), 0));
180
+ const usage = valid ? { input: Number(buckets[0]), output: Number(buckets[1]), cacheRead: Number(buckets[2]), cacheWrite: Number(buckets[3]) } : undefined;
181
+ const index = last && d.turn !== null && d.step !== null && last.turn === d.turn && last.step === d.step ? last.index : charges.length;
182
+ const key = charges[index]?.key ?? String(e.seq);
183
+ if (!usage && charges[index]?.usage)
184
+ continue;
185
+ const previous = old.get(key);
186
+ if (previous?.amount !== undefined && previous.time === time && previous.provider === provider && previous.model === model
187
+ && usage && previous.usage && Object.keys(usage).every(k => usage[k] === previous.usage[k])) {
188
+ charges[index] = previous;
189
+ last = { turn: d.turn, step: d.step, index };
190
+ continue;
191
+ }
192
+ const selected = time === undefined ? undefined : priceAt(previous?.price && previous.provider === provider && previous.model === model ? [previous.price] : this.prices, provider, model, time);
193
+ const estimate = usage && selected ? (usage.input * selected.rates.input + usage.output * selected.rates.output + usage.cacheRead * selected.rates.cacheRead + usage.cacheWrite * selected.rates.cacheWrite) / 1e6 : undefined;
194
+ const amount = estimate !== undefined && Number.isFinite(estimate) ? estimate : undefined;
195
+ charges[index] = { key, time, provider, model, usage, price: selected?.price, amount,
196
+ reason: !usage ? 'missing usage' : time === undefined ? 'missing timestamp' : !selected ? 'no price version' : amount === undefined ? 'invalid estimate' : undefined };
197
+ last = { turn: d.turn, step: d.step, index };
198
+ }
199
+ const saved = { version: 1, sessionId, cut, charges };
200
+ if (this.directory) {
201
+ const prefix = createHash('sha256').update(sessionId).digest('hex') + '-';
202
+ const filename = `${prefix}${cut}.json`;
203
+ const temporary = join(this.directory, `${randomUUID()}.tmp`);
204
+ try {
205
+ await writeFile(temporary, JSON.stringify(saved) + '\n', { mode: 0o600, flag: 'wx' });
206
+ await rename(temporary, join(this.directory, filename));
207
+ }
208
+ finally {
209
+ await unlink(temporary).catch(error => { if (error.code !== 'ENOENT')
210
+ throw error; });
211
+ }
212
+ for (const name of await readdir(this.directory))
213
+ if (name.startsWith(prefix) && name.endsWith('.json') && Number(name.slice(prefix.length, -5)) < cut) {
214
+ await unlink(join(this.directory, name)).catch(error => { if (error.code !== 'ENOENT')
215
+ throw error; });
216
+ }
217
+ }
218
+ this.sessions.set(sessionId, saved);
219
+ this.totals.clear();
220
+ }
221
+ /** Whether this session has a complete cached scan.
222
+ * @param sessionId - Selected session identity.
223
+ * @returns True when a complete scan is available.
224
+ */
225
+ hasSession(sessionId) { return sessionId !== undefined && this.sessions.has(sessionId); }
226
+ /** Describe unpriced model/usage combinations without exposing conversation content.
227
+ * @returns Unique reasons across cached sessions.
228
+ */
229
+ missing() {
230
+ return [...new Set([...this.sessions.values()].flatMap(s => s.charges.filter(c => c.amount === undefined).map(c => `${c.provider}/${c.model}: ${c.reason}`)))];
231
+ }
232
+ /** Summarize cached requests across one session or Beijing calendar days.
233
+ * @param sessionId - Optional session restriction.
234
+ * @param days - Today or today plus the preceding two calendar days.
235
+ * @param now - Clock used for date attribution.
236
+ * @returns Known subtotal and unpriced count; undated records are unpriced in every date range.
237
+ */
238
+ total(sessionId, days, now = Date.now()) {
239
+ const cacheKey = JSON.stringify([sessionId, days, days ? costDay(now) : '']);
240
+ const cached = this.totals.get(cacheKey);
241
+ if (cached)
242
+ return cached;
243
+ const result = { amount: 0, unknown: 0, records: 0 };
244
+ const end = costDay(now);
245
+ const start = costDay(now - ((days ?? 1) - 1) * 86400_000);
246
+ for (const session of this.sessions.values()) {
247
+ if (sessionId !== undefined && session.sessionId !== sessionId)
248
+ continue;
249
+ for (const charge of session.charges) {
250
+ if (days && charge.time !== undefined && (costDay(charge.time) < start || costDay(charge.time) > end))
251
+ continue;
252
+ result.records++;
253
+ if (charge.amount === undefined || days && charge.time === undefined)
254
+ result.unknown++;
255
+ else
256
+ result.amount += charge.amount;
257
+ }
258
+ }
259
+ this.totals.set(cacheKey, result);
260
+ return result;
261
+ }
262
+ }
263
+ /** Compact estimates retain an asterisk whenever a subtotal contains unpriced records.
264
+ * @param total - Summary from the ledger.
265
+ * @returns Yuan amount and incompleteness marker.
266
+ */
267
+ export function costText(total) { return `~¥${total.amount.toFixed(4)}${total.unknown ? '*' : ''}`; }
@@ -0,0 +1,16 @@
1
+ /** Resolve the host URL and startup token from the command line and environment. */
2
+ /** Bare host URL plus the startup token that applies to it. */
3
+ export interface Endpoint {
4
+ readonly url: string;
5
+ readonly token: string | undefined;
6
+ }
7
+ /**
8
+ * Split an optional `token` query parameter from the host URL.
9
+ *
10
+ * `dsh web` prints its URL with that parameter, so the printed line can be exported as
11
+ * `DSH_URL` unchanged. The token is never written to disk; only the resulting cookie is.
12
+ * @param url - Host URL, with or without a `token` query parameter.
13
+ * @param token - `DSH_TOKEN` value, which takes precedence over the URL parameter.
14
+ * @returns The URL without its `token` parameter and the effective startup token.
15
+ */
16
+ export declare function endpoint(url: string, token: string | undefined): Endpoint;
@@ -0,0 +1,16 @@
1
+ /** Resolve the host URL and startup token from the command line and environment. */
2
+ /**
3
+ * Split an optional `token` query parameter from the host URL.
4
+ *
5
+ * `dsh web` prints its URL with that parameter, so the printed line can be exported as
6
+ * `DSH_URL` unchanged. The token is never written to disk; only the resulting cookie is.
7
+ * @param url - Host URL, with or without a `token` query parameter.
8
+ * @param token - `DSH_TOKEN` value, which takes precedence over the URL parameter.
9
+ * @returns The URL without its `token` parameter and the effective startup token.
10
+ */
11
+ export function endpoint(url, token) {
12
+ const parsed = new URL(url);
13
+ const fromUrl = parsed.searchParams.get('token');
14
+ parsed.searchParams.delete('token');
15
+ return { url: parsed.href, token: token || fromUrl || undefined };
16
+ }
@@ -0,0 +1,17 @@
1
+ import type { Transcript } from './transcript.ts';
2
+ /** Lay out visible conversation records and retain their first terminal row.
3
+ * @param transcript - Loaded history and active assistant output.
4
+ * @param width - Available terminal columns.
5
+ * @returns Rows, messages for the history picker, and record-to-row offsets.
6
+ */
7
+ export declare function historyLayout(transcript: Transcript, width: number): {
8
+ messages: import("./transcript.ts").Message[];
9
+ lines: string[];
10
+ offsets: Map<number, number>;
11
+ first: number | undefined;
12
+ };
13
+ /** Parse an exact visible-record sequence or an endpoint alias.
14
+ * @param value - Text after /jump.
15
+ * @returns A non-negative sequence, first, or last.
16
+ */
17
+ export declare function jumpTarget(value: string): number | 'first' | 'last';
@@ -0,0 +1,32 @@
1
+ /** Width-aware history layout shared by scrolling and explicit message jumps. */
2
+ import wrapAnsi from 'wrap-ansi';
3
+ /** Lay out visible conversation records and retain their first terminal row.
4
+ * @param transcript - Loaded history and active assistant output.
5
+ * @param width - Available terminal columns.
6
+ * @returns Rows, messages for the history picker, and record-to-row offsets.
7
+ */
8
+ export function historyLayout(transcript, width) {
9
+ const wrap = (value) => wrapAnsi(value, width, { hard: true }).split('\n');
10
+ const messages = transcript.messagesForWidth(width);
11
+ const lines = [];
12
+ const offsets = new Map();
13
+ for (const message of messages) {
14
+ offsets.set(message.seq, lines.length);
15
+ lines.push(...(message.compact ? [] : [message.role]), ...wrap(message.text), '');
16
+ }
17
+ const live = transcript.liveTextForWidth(width);
18
+ if (live)
19
+ lines.push(...(transcript.liveToolOnly ? [] : ['Assistant · streaming']), ...wrap(live));
20
+ return { messages, lines, offsets, first: transcript.beforeSeq };
21
+ }
22
+ /** Parse an exact visible-record sequence or an endpoint alias.
23
+ * @param value - Text after /jump.
24
+ * @returns A non-negative sequence, first, or last.
25
+ */
26
+ export function jumpTarget(value) {
27
+ if (value === 'first' || value === 'last')
28
+ return value;
29
+ if (/^\d+$/.test(value) && Number.isSafeInteger(Number(value)))
30
+ return Number(value);
31
+ throw new Error('Use /jump <record sequence|first|last>; /history lists record sequences');
32
+ }
@@ -0,0 +1,23 @@
1
+ import { type Key } from 'ink';
2
+ /** Editor offsets are UTF-16 positions at grapheme boundaries; killed text stays local. */
3
+ export interface EditState {
4
+ text: string;
5
+ cursor: number;
6
+ killed: string;
7
+ }
8
+ /** Apply one Ink-decoded terminal key; application commands remain owned by the parent.
9
+ * @param state - Current text, cursor, and most recently killed text.
10
+ * @param input - Decoded text or control-key letter.
11
+ * @param key - Ink's VT/terminal key flags.
12
+ * @returns The next editor state, without sending a message or exiting.
13
+ */
14
+ export declare function editInput(state: EditState, input: string, key: Partial<Key>): EditState;
15
+ /** Controlled composer with local cursor and kill buffer; Enter submission belongs to the caller. */
16
+ export declare function TextInput({ value, onChange, onCursorChange, onSubmit, focus, placeholder }: {
17
+ value: string;
18
+ onChange(value: string): void;
19
+ onCursorChange(cursor: number): void;
20
+ onSubmit(): void;
21
+ focus: boolean;
22
+ placeholder: string;
23
+ }): import("react").JSX.Element;
package/dist/input.js ADDED
@@ -0,0 +1,103 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /** Single-line terminal editing with explicit cursor ownership and Unicode grapheme movement. */
3
+ import { useEffect, useRef, useState } from 'react';
4
+ import { isMouseReport } from "./mouse.js";
5
+ import { Text, useInput, useStdin } from 'ink';
6
+ const segments = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
7
+ /** Apply one Ink-decoded terminal key; application commands remain owned by the parent.
8
+ * @param state - Current text, cursor, and most recently killed text.
9
+ * @param input - Decoded text or control-key letter.
10
+ * @param key - Ink's VT/terminal key flags.
11
+ * @returns The next editor state, without sending a message or exiting.
12
+ */
13
+ export function editInput(state, input, key) {
14
+ const { text, cursor, killed } = state;
15
+ if (key.eventType === 'release' || key.return || key.tab || key.escape || key.upArrow || key.downArrow || key.pageUp || key.pageDown)
16
+ return state;
17
+ const previous = () => segments.segment(text).containing(cursor - 1)?.index ?? 0;
18
+ const next = () => {
19
+ const part = segments.segment(text).containing(cursor);
20
+ return part ? part.index + part.segment.length : text.length;
21
+ };
22
+ const wordStart = () => text.slice(0, cursor).replace(/\s+$/u, '').replace(/\S+$/u, '').length;
23
+ const wordEnd = () => cursor + (/^\s*\S+/u.exec(text.slice(cursor))?.[0].length ?? text.length - cursor);
24
+ const move = (position) => position === cursor ? state : ({ ...state, cursor: position });
25
+ const remove = (start, end, kill = false) => ({
26
+ text: text.slice(0, start) + text.slice(end), cursor: start,
27
+ killed: kill && start !== end ? text.slice(start, end) : killed,
28
+ });
29
+ if (key.home || key.ctrl && input === 'a')
30
+ return move(0);
31
+ if (key.end || key.ctrl && input === 'e')
32
+ return move(text.length);
33
+ if (key.meta && input === 'b' || key.ctrl && key.leftArrow)
34
+ return move(wordStart());
35
+ if (key.meta && input === 'f' || key.ctrl && key.rightArrow)
36
+ return move(wordEnd());
37
+ if (key.leftArrow || key.ctrl && input === 'b')
38
+ return move(previous());
39
+ if (key.rightArrow || key.ctrl && input === 'f')
40
+ return move(next());
41
+ if (key.ctrl && input === 'k')
42
+ return remove(cursor, text.length, true);
43
+ if (key.ctrl && input === 'u')
44
+ return remove(0, cursor, true);
45
+ if (key.ctrl && input === 'w' || key.meta && key.backspace)
46
+ return remove(wordStart(), cursor, true);
47
+ if (key.meta && input === 'd')
48
+ return remove(cursor, wordEnd(), true);
49
+ if (key.backspace || key.ctrl && input === 'h')
50
+ return remove(previous(), cursor);
51
+ if (key.delete || key.ctrl && input === 'd')
52
+ return remove(cursor, next());
53
+ if (key.ctrl && input === 'y')
54
+ return { ...state, text: text.slice(0, cursor) + killed + text.slice(cursor), cursor: cursor + killed.length };
55
+ if (key.ctrl || key.meta || key.super || key.hyper)
56
+ return state;
57
+ const inserted = input.replace(/[\r\n\t]+/g, ' ').replace(/[\u0000-\u001f\u007f-\u009f]/g, '');
58
+ if (!inserted)
59
+ return state;
60
+ return { ...state, text: text.slice(0, cursor) + inserted + text.slice(cursor), cursor: cursor + inserted.length };
61
+ }
62
+ /** Controlled composer with local cursor and kill buffer; Enter submission belongs to the caller. */
63
+ export function TextInput({ value, onChange, onCursorChange, onSubmit, focus, placeholder }) {
64
+ const { internal_eventEmitter } = useStdin();
65
+ const rawKey = useRef('');
66
+ useEffect(() => {
67
+ // Ink 6 merges DEL (backspace) and CSI 3~ (forward delete) into key.delete.
68
+ // Capture the same decoded input event before useInput discards its raw bytes.
69
+ const remember = (raw) => { rawKey.current = raw; };
70
+ internal_eventEmitter.prependListener('input', remember);
71
+ return () => { internal_eventEmitter.removeListener('input', remember); };
72
+ }, [internal_eventEmitter]);
73
+ const current = useRef({ text: value, cursor: value.length, killed: '' });
74
+ const [, redraw] = useState(0);
75
+ if (current.current.text !== value)
76
+ current.current = { ...current.current, text: value, cursor: value.length };
77
+ useInput((input, key) => {
78
+ if (key.eventType === 'release' || isMouseReport(rawKey.current))
79
+ return;
80
+ if (key.return) {
81
+ onSubmit();
82
+ return;
83
+ }
84
+ const before = current.current;
85
+ const backspace = rawKey.current === '\x7f' || rawKey.current === '\x1b\x7f'
86
+ || /^\x1b\[127(?:;[\d:]+)?u$/.test(rawKey.current);
87
+ const after = editInput(before, input, key.delete && backspace ? { ...key, delete: false, backspace: true } : key);
88
+ current.current = after;
89
+ if (after.text !== before.text)
90
+ onChange(after.text);
91
+ if (after.cursor !== before.cursor || after.text !== before.text)
92
+ onCursorChange(after.cursor);
93
+ if (after.text === before.text && after.cursor !== before.cursor)
94
+ redraw(value => value + 1);
95
+ }, { isActive: focus });
96
+ const { text, cursor } = current.current;
97
+ const character = segments.segment(text).containing(cursor)?.segment ?? ' ';
98
+ if (!text && !focus)
99
+ return _jsx(Text, { dimColor: true, children: placeholder });
100
+ if (!text)
101
+ return _jsxs(Text, { children: [_jsx(Text, { inverse: true, children: placeholder[0] ?? ' ' }), _jsx(Text, { dimColor: true, children: placeholder.slice(1) })] });
102
+ return _jsxs(Text, { children: [text.slice(0, cursor), _jsx(Text, { inverse: focus, children: character }), text.slice(cursor + character.length)] });
103
+ }
@@ -0,0 +1,14 @@
1
+ /** Recognize complete SGR reports so clicks and wheel bytes never become prompt text.
2
+ * @param raw - One Ink input-parser event.
3
+ * @returns Whether this is a mouse report, including non-wheel buttons.
4
+ */
5
+ export declare function isMouseReport(raw: string): boolean;
6
+ /** Decode vertical wheel presses, ignoring releases, motion and horizontal wheels.
7
+ * @param raw - One complete SGR mouse report.
8
+ * @returns Positive for older history, negative for newer history, or zero.
9
+ */
10
+ export declare function wheelDirection(raw: string): number;
11
+ /** Enable cell-based mouse reports for this mount and restore normal terminal behavior on exit.
12
+ * @param scroll - Current transcript scrolling callback.
13
+ */
14
+ export declare function useMouseWheel(scroll: (direction: number) => void): void;
package/dist/mouse.js ADDED
@@ -0,0 +1,41 @@
1
+ /** SGR mouse reporting, shared by transcript scrolling and input suppression. */
2
+ import { useEffect, useRef } from 'react';
3
+ import { useStdin, useStdout } from 'ink';
4
+ /** Recognize complete SGR reports so clicks and wheel bytes never become prompt text.
5
+ * @param raw - One Ink input-parser event.
6
+ * @returns Whether this is a mouse report, including non-wheel buttons.
7
+ */
8
+ export function isMouseReport(raw) { return /^\x1b\[<\d+;\d+;\d+[Mm]$/.test(raw); }
9
+ /** Decode vertical wheel presses, ignoring releases, motion and horizontal wheels.
10
+ * @param raw - One complete SGR mouse report.
11
+ * @returns Positive for older history, negative for newer history, or zero.
12
+ */
13
+ export function wheelDirection(raw) {
14
+ const match = /^\x1b\[<(\d+);\d+;\d+M$/.exec(raw);
15
+ if (!match)
16
+ return 0;
17
+ const button = Number(match[1]);
18
+ const base = button & ~28;
19
+ return base === 64 ? 1 : base === 65 ? -1 : 0;
20
+ }
21
+ /** Enable cell-based mouse reports for this mount and restore normal terminal behavior on exit.
22
+ * @param scroll - Current transcript scrolling callback.
23
+ */
24
+ export function useMouseWheel(scroll) {
25
+ const { internal_eventEmitter } = useStdin();
26
+ const { stdout } = useStdout();
27
+ const callback = useRef(scroll);
28
+ callback.current = scroll;
29
+ useEffect(() => {
30
+ const onInput = (raw) => { const direction = wheelDirection(raw); if (direction)
31
+ callback.current(direction); };
32
+ internal_eventEmitter.on('input', onInput);
33
+ if (stdout.isTTY)
34
+ stdout.write('\x1b[?1006h\x1b[?1000h');
35
+ return () => {
36
+ internal_eventEmitter.removeListener('input', onInput);
37
+ if (stdout.isTTY)
38
+ stdout.write('\x1b[?1000l\x1b[?1006l');
39
+ };
40
+ }, [internal_eventEmitter, stdout]);
41
+ }
@@ -0,0 +1,11 @@
1
+ /** Shared display names and unambiguous slash-command target resolution. */
2
+ import { type ObjectValue } from './wire.ts';
3
+ /** Parse the short navigation commands and their equivalent long aliases. */
4
+ export declare function navigationCommand(value: string): {
5
+ kind: 'workspace' | 'session';
6
+ query?: string;
7
+ } | undefined;
8
+ /** Resolve the host's title projection, falling back to the session ID. */
9
+ export declare function sessionLabel(session: ObjectValue): string;
10
+ /** Match an exact ID or name before a unique ID prefix; never choose an ambiguous target. */
11
+ export declare function resolveTarget(items: ObjectValue[], query: string, id: string, names: (item: ObjectValue) => string[]): ObjectValue;