@itookit/dsht 0.5.1 → 0.5.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 (66) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +4 -2
  3. package/README.zh.md +6 -4
  4. package/dist/catalog/controller.d.ts +26 -6
  5. package/dist/catalog/controller.js +73 -45
  6. package/dist/catalog/index.d.ts +1 -0
  7. package/dist/cli/dsht.js +4 -1
  8. package/dist/cli/startup.js +30 -11
  9. package/dist/cli/verifier.d.ts +4 -0
  10. package/dist/cli/verifier.js +28 -5
  11. package/dist/contracts.d.ts +20 -5
  12. package/dist/controller/connection-streams.d.ts +22 -0
  13. package/dist/controller/connection-streams.js +105 -0
  14. package/dist/controller/connection.d.ts +14 -3
  15. package/dist/controller/connection.js +40 -69
  16. package/dist/controller/controller.d.ts +17 -233
  17. package/dist/controller/controller.js +108 -811
  18. package/dist/controller/foreground.d.ts +44 -0
  19. package/dist/controller/foreground.js +79 -0
  20. package/dist/controller/loop-coordinator.d.ts +48 -0
  21. package/dist/controller/loop-coordinator.js +647 -0
  22. package/dist/controller/verifier.d.ts +4 -0
  23. package/dist/cost/controller.d.ts +1 -1
  24. package/dist/cost/controller.js +12 -5
  25. package/dist/cost/scanner.js +1 -0
  26. package/dist/session/controller.d.ts +23 -35
  27. package/dist/session/controller.js +113 -363
  28. package/dist/session/history-reader.d.ts +32 -0
  29. package/dist/session/history-reader.js +170 -0
  30. package/dist/session/index.d.ts +1 -1
  31. package/dist/session/info.d.ts +3 -38
  32. package/dist/session/info.js +14 -1
  33. package/dist/session/interactions.d.ts +26 -0
  34. package/dist/session/interactions.js +75 -0
  35. package/dist/session/navigator.d.ts +47 -0
  36. package/dist/session/navigator.js +158 -0
  37. package/dist/session/prompt-backfill.d.ts +23 -0
  38. package/dist/session/prompt-backfill.js +88 -0
  39. package/dist/session/state.d.ts +20 -0
  40. package/dist/session/state.js +1 -0
  41. package/dist/session/telemetry.d.ts +15 -6
  42. package/dist/session/telemetry.js +44 -7
  43. package/dist/session/transcript.d.ts +5 -1
  44. package/dist/slash/index.d.ts +1 -1
  45. package/dist/slash/parse.d.ts +2 -126
  46. package/dist/slash/registry.d.ts +1 -1
  47. package/dist/slash/types.d.ts +126 -0
  48. package/dist/slash/types.js +1 -0
  49. package/dist/state.d.ts +5 -17
  50. package/dist/state.js +1 -1
  51. package/dist/transport/client.d.ts +4 -3
  52. package/dist/transport/client.js +71 -25
  53. package/dist/ui/app.js +86 -301
  54. package/dist/ui/chat/shell-view.d.ts +2 -0
  55. package/dist/ui/chat/shell-view.js +8 -0
  56. package/dist/ui/chat/use-history-view.d.ts +69 -0
  57. package/dist/ui/chat/use-history-view.js +123 -0
  58. package/dist/ui/dialogs/use-panels.d.ts +53 -0
  59. package/dist/ui/dialogs/use-panels.js +51 -0
  60. package/dist/ui/input/use-composer.d.ts +35 -0
  61. package/dist/ui/input/use-composer.js +109 -0
  62. package/dist/ui/input/use-deferred-lines.d.ts +16 -0
  63. package/dist/ui/input/use-deferred-lines.js +54 -0
  64. package/dist/ui/input/use-history-recall.d.ts +20 -0
  65. package/dist/ui/input/use-history-recall.js +47 -0
  66. package/package.json +1 -1
@@ -0,0 +1,69 @@
1
+ /** Reading position, folds and asynchronous navigation belong to this mounted conversation view. */
2
+ import { type SetStateAction } from 'react';
3
+ import type { Reasoning, SessionRender, ShellBlock } from '../../contracts.ts';
4
+ interface HistorySource {
5
+ readonly version: number;
6
+ readonly memoryRevision: number;
7
+ readonly beforeSeq: number | undefined;
8
+ readonly ready: boolean;
9
+ readonly hasMore: boolean;
10
+ readonly liveAttemptKey: string | undefined;
11
+ }
12
+ interface HistoryViewOptions<S extends HistorySource> {
13
+ sessionId?: string;
14
+ source: S;
15
+ display: S;
16
+ detached: boolean;
17
+ width: number;
18
+ rows: number;
19
+ showHint: boolean;
20
+ protect: boolean;
21
+ enabled: boolean;
22
+ online: boolean;
23
+ busy: boolean;
24
+ revision: number;
25
+ blocks: readonly ShellBlock[];
26
+ current(source: S): boolean;
27
+ render(input: {
28
+ transcript: S;
29
+ width: number;
30
+ folds: ReadonlySet<number>;
31
+ liveReasoning: Reasoning;
32
+ }): SessionRender;
33
+ run<T>(work: (signal: AbortSignal) => Promise<T>, label: string): Promise<T | undefined>;
34
+ older(signal: AbortSignal, source: S): Promise<boolean>;
35
+ open(target: number, signal: AbortSignal): Promise<boolean>;
36
+ pin(value: boolean): void;
37
+ closePanels(): void;
38
+ notify(message: string): void;
39
+ }
40
+ export declare function useHistoryView<S extends HistorySource>(options: HistoryViewOptions<S>): {
41
+ layout: {
42
+ messages: import("../../contracts.ts").Message[];
43
+ offsets: Map<number, number>;
44
+ first: number | undefined;
45
+ length: number;
46
+ liveOffset: number;
47
+ viewport: (start: number, end: number) => import("../../contracts.ts").HistoryRow[];
48
+ readonly cachedRowCount: number;
49
+ readonly lines: string[];
50
+ };
51
+ merged: {
52
+ total: number;
53
+ hostRow(row: number): number;
54
+ sources: ReadonlyMap<number, string>;
55
+ viewport(start: number, end: number): import("../../contracts.ts").HistoryRow[];
56
+ };
57
+ totalRows: number;
58
+ pageSize: number;
59
+ position: number;
60
+ reasoningOverrides: ReadonlySet<number>;
61
+ liveReasoning: Reasoning;
62
+ setScroll: (value: SetStateAction<number>) => void;
63
+ setReasoningOverrides: (value: SetStateAction<ReadonlySet<number>>) => void;
64
+ setLiveReasoning: (value: SetStateAction<Reasoning>) => void;
65
+ scrollHistory: (delta: number) => void;
66
+ jumpHistory: (target: number) => Promise<void>;
67
+ historyOperation: <T>(work: (signal: AbortSignal) => Promise<T>, label?: string) => Promise<T | undefined>;
68
+ };
69
+ export {};
@@ -0,0 +1,123 @@
1
+ /** Reading position, folds and asynchronous navigation belong to this mounted conversation view. */
2
+ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
3
+ import { errorText } from "../../text.js";
4
+ import { mergeShellRuns } from "./shell-view.js";
5
+ export function useHistoryView(options) {
6
+ const [scroll, setScroll] = useState(0);
7
+ const [folds, setFolds] = useState(new Set());
8
+ const [liveReasoning, setLive] = useState('row');
9
+ const [anchor, setAnchor] = useState();
10
+ const latest = useRef(options);
11
+ latest.current = options;
12
+ const mounted = useRef(true);
13
+ const intent = useRef(0);
14
+ const jumpAbort = useRef(undefined);
15
+ const pageAbort = useRef(undefined);
16
+ const lifetime = useRef(new AbortController());
17
+ const paging = useRef(false);
18
+ const invalidate = () => { intent.current++; jumpAbort.current?.abort(); setAnchor(undefined); };
19
+ useEffect(() => {
20
+ if (lifetime.current.signal.aborted)
21
+ lifetime.current = new AbortController();
22
+ mounted.current = true;
23
+ return () => {
24
+ mounted.current = false;
25
+ intent.current++;
26
+ lifetime.current.abort();
27
+ jumpAbort.current?.abort();
28
+ pageAbort.current?.abort();
29
+ latest.current.pin(false);
30
+ };
31
+ }, []);
32
+ useEffect(() => {
33
+ invalidate();
34
+ pageAbort.current?.abort();
35
+ setScroll(0);
36
+ }, [options.source]);
37
+ useEffect(() => { setFolds(new Set()); setLive('row'); }, [options.sessionId]);
38
+ useEffect(() => { setLive('row'); }, [options.source, options.source.liveAttemptKey]);
39
+ useEffect(() => {
40
+ const first = options.display.beforeSeq;
41
+ if (first !== undefined)
42
+ setFolds(previous => {
43
+ const next = new Set([...previous].filter(seq => seq >= first));
44
+ return next.size === previous.size ? previous : next;
45
+ });
46
+ }, [options.display, options.display.memoryRevision]);
47
+ const { source, display, width } = options;
48
+ const layout = useMemo(() => options.render({ transcript: display, width, folds, liveReasoning }), [display, display.version, width, folds, liveReasoning]);
49
+ const merged = useMemo(() => mergeShellRuns(layout, options.blocks, width), [layout, options.blocks, options.revision, width]);
50
+ const pageSize = Math.max(1, options.rows - (options.showHint ? 1 : 0));
51
+ const totalRows = merged.total;
52
+ const maxScroll = Math.max(0, totalRows - pageSize);
53
+ const previousView = useRef({ source, display, count: totalRows, first: layout.first, folds, liveReasoning });
54
+ const previous = previousView.current;
55
+ const prepended = previous.first !== undefined && layout.first !== undefined && layout.first < previous.first;
56
+ const adjusted = previous.source !== source ? 0
57
+ : scroll > 0 && previous.display === display && !prepended && previous.folds === folds && previous.liveReasoning === liveReasoning
58
+ ? Math.max(0, scroll + totalRows - previous.count) : scroll;
59
+ const row = anchor?.source === source ? layout.offsets.get(anchor.target) : undefined;
60
+ const position = Math.max(0, Math.min(row === undefined ? adjusted : totalRows - pageSize - merged.hostRow(row), maxScroll));
61
+ useLayoutEffect(() => {
62
+ previousView.current = { source, display, count: totalRows, first: layout.first, folds, liveReasoning };
63
+ if (position !== scroll)
64
+ setScroll(position);
65
+ if (anchor)
66
+ setAnchor(undefined);
67
+ }, [source, display, totalRows, position, scroll, folds, liveReasoning, anchor]);
68
+ useLayoutEffect(() => { options.pin(!options.detached && (position > 0 || options.protect)); }, [options.detached, position, options.protect, source]);
69
+ function scrollTo(value) { invalidate(); setScroll(value); }
70
+ function setReasoningOverrides(value) { invalidate(); setFolds(value); }
71
+ function setLiveReasoning(value) { invalidate(); setLive(value); }
72
+ function historyOperation(work, label = 'Loading history…') {
73
+ return options.run(signal => work(AbortSignal.any([signal, lifetime.current.signal])), label);
74
+ }
75
+ function scrollHistory(delta) {
76
+ if (!options.enabled)
77
+ return;
78
+ invalidate();
79
+ const own = intent.current;
80
+ const next = Math.max(0, Math.min(maxScroll, position + delta));
81
+ setScroll(next);
82
+ if (delta <= 0 || next < maxScroll || paging.current || options.busy || !options.online || !display.ready || !display.hasMore)
83
+ return;
84
+ paging.current = true;
85
+ const abort = new AbortController();
86
+ pageAbort.current = abort;
87
+ void historyOperation(signal => options.older(AbortSignal.any([signal, abort.signal]), display)).then(accepted => {
88
+ if (accepted && mounted.current && latest.current.display === display && options.current(source) && intent.current === own) {
89
+ setScroll(position + delta);
90
+ }
91
+ }).catch(error => {
92
+ if (mounted.current && !(error instanceof Error && error.name === 'AbortError'))
93
+ latest.current.notify(errorText(error));
94
+ }).finally(() => { paging.current = false; if (pageAbort.current === abort)
95
+ pageAbort.current = undefined; });
96
+ }
97
+ async function jumpHistory(target) {
98
+ if (!options.enabled)
99
+ throw new Error('Select a session first');
100
+ invalidate();
101
+ const own = intent.current;
102
+ const abort = new AbortController();
103
+ jumpAbort.current = abort;
104
+ try {
105
+ await historyOperation(async (foreground) => {
106
+ const signal = AbortSignal.any([foreground, abort.signal]);
107
+ if (!await options.open(target, signal))
108
+ return;
109
+ signal.throwIfAborted();
110
+ if (!mounted.current || intent.current !== own || !options.current(source))
111
+ return;
112
+ setAnchor({ source, target });
113
+ latest.current.closePanels();
114
+ }, 'Jumping to a message…');
115
+ }
116
+ finally {
117
+ if (jumpAbort.current === abort)
118
+ jumpAbort.current = undefined;
119
+ }
120
+ }
121
+ return { layout, merged, totalRows, pageSize, position, reasoningOverrides: folds, liveReasoning,
122
+ setScroll: scrollTo, setReasoningOverrides, setLiveReasoning, scrollHistory, jumpHistory, historyOperation };
123
+ }
@@ -0,0 +1,53 @@
1
+ /** UI-owned dialog state and the keyboard traits shared by routing and rendering. */
2
+ import { type SetStateAction } from 'react';
3
+ import type { HistoryPanel, ModelState, PanelName, RemovalTarget, SearchPanel } from '../../contracts.ts';
4
+ type SurfaceName = PanelName | 'peek';
5
+ interface PanelState {
6
+ thoughts: boolean;
7
+ queue: boolean;
8
+ prompts: boolean;
9
+ help: boolean;
10
+ cost: boolean;
11
+ status: boolean;
12
+ model?: ModelState;
13
+ history?: HistoryPanel;
14
+ search?: SearchPanel;
15
+ removal?: RemovalTarget;
16
+ loop?: {
17
+ name: string;
18
+ };
19
+ }
20
+ interface Surface {
21
+ name: SurfaceName;
22
+ open: boolean;
23
+ arrows?: boolean;
24
+ blocksKeys?: boolean;
25
+ reserved?: readonly string[];
26
+ }
27
+ /** The externally owned peek view participates in routing without duplicating its state. */
28
+ export declare function usePanels(options: {
29
+ sessionId: string | undefined;
30
+ pendingId: string | undefined;
31
+ peekOpen: boolean;
32
+ closePeek(): void;
33
+ }): {
34
+ state: PanelState;
35
+ openSurfaces: Surface[];
36
+ close: (name: SurfaceName) => void;
37
+ closeExcept: (keep?: SurfaceName) => void;
38
+ reservedKeys: string[];
39
+ openThoughts: (value: SetStateAction<boolean>) => void;
40
+ openQueue: (value: SetStateAction<boolean>) => void;
41
+ openPrompts: (value: SetStateAction<boolean>) => void;
42
+ setModelPanel: (value: SetStateAction<ModelState | undefined>) => void;
43
+ setHistoryPanel: (value: SetStateAction<HistoryPanel | undefined>) => void;
44
+ setSearchPanel: (value: SetStateAction<SearchPanel | undefined>) => void;
45
+ setRemoval: (value: SetStateAction<RemovalTarget | undefined>) => void;
46
+ setLoopForm: (value: SetStateAction<{
47
+ name: string;
48
+ } | undefined>) => void;
49
+ setHelp: (value: SetStateAction<boolean>) => void;
50
+ setCostExpanded: (value: SetStateAction<boolean>) => void;
51
+ setStatusExpanded: (value: SetStateAction<boolean>) => void;
52
+ };
53
+ export {};
@@ -0,0 +1,51 @@
1
+ /** UI-owned dialog state and the keyboard traits shared by routing and rendering. */
2
+ import { useEffect, useState } from 'react';
3
+ const EMPTY = { thoughts: false, queue: false, prompts: false, help: false, cost: false, status: false };
4
+ const TRAITS = [
5
+ { name: 'queue', reserved: ['d'] },
6
+ { name: 'prompts', arrows: true, blocksKeys: true, reserved: ['d', 'e'] },
7
+ ...['removal', 'model', 'loop', 'thoughts', 'history', 'search'].map(name => ({ name, arrows: true, blocksKeys: true })),
8
+ ...['help', 'cost', 'status'].map(name => ({ name, blocksKeys: true })),
9
+ ];
10
+ /** The externally owned peek view participates in routing without duplicating its state. */
11
+ export function usePanels(options) {
12
+ const [state, setState] = useState(EMPTY);
13
+ const setter = (key) => (value) => {
14
+ setState(current => {
15
+ const next = typeof value === 'function' ? value(current[key]) : value;
16
+ return current[key] === next ? current : { ...current, [key]: next };
17
+ });
18
+ };
19
+ useEffect(() => {
20
+ // Read-only client panels and a chosen removal target survive navigation as before.
21
+ setState(current => ({ ...EMPTY, help: current.help, cost: current.cost, status: current.status, removal: current.removal }));
22
+ }, [options.sessionId]);
23
+ useEffect(() => {
24
+ setState(current => ({ ...current, queue: false, loop: options.pendingId === undefined ? current.loop : undefined }));
25
+ }, [options.sessionId, options.pendingId]);
26
+ const surfaces = [
27
+ { name: 'peek', open: options.peekOpen, arrows: true, blocksKeys: true },
28
+ ...TRAITS.map(trait => ({ ...trait, open: trait.name === 'queue' ? state.queue && options.pendingId === undefined
29
+ : !!state[trait.name] })),
30
+ ];
31
+ const openSurfaces = surfaces.filter(surface => surface.open);
32
+ function close(name) {
33
+ if (name === 'peek')
34
+ options.closePeek();
35
+ else
36
+ setState(current => ({ ...current, [name]: EMPTY[name] }));
37
+ }
38
+ function closeExcept(keep) {
39
+ if (keep !== 'peek')
40
+ options.closePeek();
41
+ setState(current => keep === undefined || keep === 'peek' ? EMPTY : { ...EMPTY, [keep]: current[keep] });
42
+ }
43
+ return {
44
+ state, openSurfaces, close, closeExcept,
45
+ reservedKeys: [...new Set(openSurfaces.flatMap(surface => surface.reserved ?? []))],
46
+ openThoughts: setter('thoughts'), openQueue: setter('queue'), openPrompts: setter('prompts'),
47
+ setModelPanel: setter('model'), setHistoryPanel: setter('history'), setSearchPanel: setter('search'),
48
+ setRemoval: setter('removal'), setLoopForm: setter('loop'),
49
+ setHelp: setter('help'), setCostExpanded: setter('cost'), setStatusExpanded: setter('status'),
50
+ };
51
+ }
@@ -0,0 +1,35 @@
1
+ /** A local editor borrows Enter until it is saved or dismissed. */
2
+ export interface ComposerIntent {
3
+ hint: string;
4
+ emptyNotice: string;
5
+ successNotice?: string;
6
+ commit(text: string): Promise<boolean>;
7
+ }
8
+ /** Identity of a submitted draft, even if a later edit has identical text. */
9
+ export interface DraftReceipt {
10
+ readonly sessionId: string | undefined;
11
+ readonly revision: number;
12
+ }
13
+ interface ComposerOptions {
14
+ sessionId: string | undefined;
15
+ /** Read at completion too, before React necessarily renders a session change. */
16
+ currentSession(): string | undefined;
17
+ resetRecall(): void;
18
+ messageStarted(): void;
19
+ notify(message: string): void;
20
+ }
21
+ /** Owns editor state only; command interpretation and keyboard routing stay in App. */
22
+ export declare function useComposer(options: ComposerOptions): {
23
+ input: string;
24
+ cursor: number;
25
+ intent: ComposerIntent | undefined;
26
+ setInput: (value: string, recalled?: boolean) => void;
27
+ setCursor: import("react").Dispatch<import("react").SetStateAction<number>>;
28
+ setIntent: (value: ComposerIntent | undefined) => void;
29
+ capture: () => DraftReceipt;
30
+ consume: (receipt: DraftReceipt) => void;
31
+ recallIfCurrent: (receipt: DraftReceipt, read: () => string) => void;
32
+ commit: (raw: string) => Promise<boolean>;
33
+ interactionChanged: (answerPending: boolean, pending: boolean) => void;
34
+ };
35
+ export {};
@@ -0,0 +1,109 @@
1
+ /** Session-owned draft, temporary editors and async submission ownership. */
2
+ import { useLayoutEffect, useRef, useState } from 'react';
3
+ import { errorText } from "../../text.js";
4
+ /** Owns editor state only; command interpretation and keyboard routing stay in App. */
5
+ export function useComposer(options) {
6
+ const [input, setInputValue] = useState('');
7
+ const [cursor, setCursor] = useState(0);
8
+ const [intent, publishIntent] = useState();
9
+ const current = useRef({ sessionId: options.sessionId, revision: 0, input: '', parked: '', intent: undefined });
10
+ const mounted = useRef(false);
11
+ const saving = useRef(new WeakSet());
12
+ function replace(value) {
13
+ current.current.input = value;
14
+ current.current.revision += 1;
15
+ setInputValue(value);
16
+ setCursor(value.length);
17
+ }
18
+ function setIntent(value) {
19
+ current.current.intent = value;
20
+ publishIntent(value);
21
+ }
22
+ useLayoutEffect(() => {
23
+ mounted.current = true;
24
+ return () => { mounted.current = false; };
25
+ }, []);
26
+ useLayoutEffect(() => {
27
+ if (current.current.sessionId === options.sessionId)
28
+ return;
29
+ current.current.sessionId = options.sessionId;
30
+ current.current.parked = '';
31
+ setIntent(undefined);
32
+ replace('');
33
+ }, [options.sessionId]);
34
+ function setInput(value, recalled = false) {
35
+ if (!recalled)
36
+ options.resetRecall();
37
+ if (current.current.input === '' && value !== '' && !value.startsWith('/'))
38
+ options.messageStarted();
39
+ replace(value);
40
+ }
41
+ function capture() {
42
+ return { sessionId: current.current.sessionId, revision: current.current.revision };
43
+ }
44
+ function owns(receipt) {
45
+ return mounted.current && receipt.sessionId === options.currentSession()
46
+ && receipt.sessionId === current.current.sessionId && receipt.revision === current.current.revision;
47
+ }
48
+ function consume(receipt) {
49
+ if (owns(receipt))
50
+ setInput('');
51
+ }
52
+ /** A slow recall must neither replace newer typing nor move its recall cursor. */
53
+ function recallIfCurrent(receipt, read) {
54
+ if (owns(receipt))
55
+ setInput(read(), true);
56
+ }
57
+ /** Returns true whenever an editor handled Enter, including an already pending save. */
58
+ async function commit(raw) {
59
+ const editor = current.current.intent;
60
+ if (!editor)
61
+ return false;
62
+ if (saving.current.has(editor))
63
+ return true;
64
+ const text = raw.trim();
65
+ if (!text) {
66
+ options.notify(editor.emptyNotice);
67
+ return true;
68
+ }
69
+ const receipt = capture();
70
+ const active = () => mounted.current && current.current.intent === editor
71
+ && receipt.sessionId === options.currentSession() && receipt.sessionId === current.current.sessionId;
72
+ saving.current.add(editor);
73
+ try {
74
+ if (await editor.commit(text) && active()) {
75
+ if (editor.successNotice)
76
+ options.notify(editor.successNotice);
77
+ // Continued typing remains in edit mode for the next explicit save.
78
+ if (owns(receipt)) {
79
+ setIntent(undefined);
80
+ consume(receipt);
81
+ }
82
+ }
83
+ }
84
+ catch (error) {
85
+ if (active())
86
+ options.notify(errorText(error));
87
+ }
88
+ finally {
89
+ saving.current.delete(editor);
90
+ }
91
+ return true;
92
+ }
93
+ /** Parking/restoring is not a recall or a new message, so it does not move the reader. */
94
+ function interactionChanged(answerPending, pending) {
95
+ if (answerPending) {
96
+ setIntent(undefined);
97
+ if (current.current.input !== '') {
98
+ current.current.parked = current.current.input;
99
+ replace('');
100
+ }
101
+ }
102
+ else if (!pending && current.current.parked !== '') {
103
+ const parked = current.current.parked;
104
+ current.current.parked = '';
105
+ replace(parked);
106
+ }
107
+ }
108
+ return { input, cursor, intent, setInput, setCursor, setIntent, capture, consume, recallIfCurrent, commit, interactionChanged };
109
+ }
@@ -0,0 +1,16 @@
1
+ import { type AuthorizeFacts, type DeferReason, type LineCommand } from '../../slash/index.ts';
2
+ interface DeferredFacts extends AuthorizeFacts {
3
+ sessionId: string | undefined;
4
+ /** Both the connection baseline and the selected conversation snapshot must be restored. */
5
+ ready: boolean;
6
+ }
7
+ interface DeferredOptions {
8
+ facts(): DeferredFacts;
9
+ run(command: LineCommand): Promise<void>;
10
+ notify(message: string): void;
11
+ }
12
+ /** Owns FIFO and in-flight state, independent of Controller and the composer's current draft. */
13
+ export declare function useDeferredLines(options: DeferredOptions): {
14
+ enqueue(command: LineCommand, defer: DeferReason): void;
15
+ };
16
+ export {};
@@ -0,0 +1,54 @@
1
+ /** Commands explicitly accepted by the operator, waiting for their execution policy. */
2
+ import { useEffect, useRef, useState } from 'react';
3
+ import { authorize } from "../../slash/index.js";
4
+ import { errorText } from "../../text.js";
5
+ /** Owns FIFO and in-flight state, independent of Controller and the composer's current draft. */
6
+ export function useDeferredLines(options) {
7
+ const [lines, setLines] = useState([]);
8
+ const draining = useRef(false);
9
+ const mounted = useRef(false);
10
+ useEffect(() => {
11
+ mounted.current = true;
12
+ return () => { mounted.current = false; };
13
+ }, []);
14
+ // Re-evaluate on UI publications, including connection/snapshot readiness and a completed run.
15
+ // A synchronous reservation prevents a re-render or StrictMode replay from starting it twice.
16
+ useEffect(() => {
17
+ const next = lines[0];
18
+ if (!next || draining.current)
19
+ return;
20
+ const facts = options.facts();
21
+ if (next.sessionId !== facts.sessionId) {
22
+ setLines(current => current.filter(line => line.sessionId === facts.sessionId));
23
+ options.notify('Dropped a queued line: the selected session changed');
24
+ return;
25
+ }
26
+ if (!facts.ready || facts.foreground || (next.defer !== 'busy' && facts.during !== 'idle'))
27
+ return;
28
+ const verdict = authorize(next.command, facts);
29
+ if (verdict.allow && verdict.defer !== undefined)
30
+ return;
31
+ draining.current = true;
32
+ setLines(current => current.slice(1));
33
+ void (async () => {
34
+ try {
35
+ await options.run(verdict.allow ? verdict.command : verdict.error);
36
+ }
37
+ catch (error) {
38
+ if (mounted.current)
39
+ options.notify(errorText(error));
40
+ }
41
+ finally {
42
+ draining.current = false;
43
+ if (mounted.current)
44
+ setLines(current => [...current]);
45
+ }
46
+ })();
47
+ });
48
+ return {
49
+ enqueue(command, defer) {
50
+ const sessionId = options.facts().sessionId;
51
+ setLines(current => [...current, { command, defer, sessionId }]);
52
+ },
53
+ };
54
+ }
@@ -0,0 +1,20 @@
1
+ import type { DraftReceipt } from './use-composer.ts';
2
+ interface RecallSource {
3
+ readonly hasMore: boolean;
4
+ readonly beforeSeq: number | undefined;
5
+ }
6
+ interface RecallOptions<Source extends RecallSource> {
7
+ source: Source;
8
+ draft: string;
9
+ capture(): DraftReceipt;
10
+ restore(receipt: DraftReceipt, read: () => string): void;
11
+ previous(draft: string): string;
12
+ refill(): boolean;
13
+ hasPrevious(): boolean;
14
+ older(signal: AbortSignal, source: Source): Promise<boolean>;
15
+ run(work: (signal: AbortSignal) => Promise<boolean>): Promise<boolean | undefined>;
16
+ notify(message: string): void;
17
+ }
18
+ /** No Controller or Transcript dependency: only the paging and recall capabilities it needs. */
19
+ export declare function useHistoryRecall<Source extends RecallSource>(options: RecallOptions<Source>): () => Promise<void>;
20
+ export {};
@@ -0,0 +1,47 @@
1
+ /** Lazy prompt recall owns its scan; the composer decides whether the result still belongs to it. */
2
+ import { useRef } from 'react';
3
+ import { errorText } from "../../text.js";
4
+ /** Bound one key press even when many tool-only pages separate user messages. */
5
+ const PAGE_SCAN_LIMIT = 20;
6
+ /** No Controller or Transcript dependency: only the paging and recall capabilities it needs. */
7
+ export function useHistoryRecall(options) {
8
+ const loading = useRef(false);
9
+ return async () => {
10
+ if (loading.current)
11
+ return;
12
+ const receipt = options.capture();
13
+ const { source, draft } = options;
14
+ const restore = () => options.restore(receipt, () => options.previous(draft));
15
+ if (options.refill() || options.hasPrevious() || !source.hasMore) {
16
+ restore();
17
+ return;
18
+ }
19
+ loading.current = true;
20
+ try {
21
+ const found = await options.run(async (signal) => {
22
+ for (let page = 0; page < PAGE_SCAN_LIMIT; page++) {
23
+ signal.throwIfAborted();
24
+ if (source.beforeSeq === undefined || !source.hasMore)
25
+ break;
26
+ if (!await options.older(signal, source))
27
+ break;
28
+ signal.throwIfAborted();
29
+ // Startup backfill may have inserted the same prompts while this page was in flight.
30
+ if (options.refill() || options.hasPrevious())
31
+ return true;
32
+ }
33
+ return false;
34
+ });
35
+ if (found)
36
+ restore();
37
+ }
38
+ catch (error) {
39
+ // Foreground cancellation is an expected end to a user-triggered scan.
40
+ if (!(error instanceof Error && error.name === 'AbortError'))
41
+ options.notify(errorText(error));
42
+ }
43
+ finally {
44
+ loading.current = false;
45
+ }
46
+ };
47
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@itookit/dsht",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
4
4
  "type": "module",
5
5
  "description": "Remote-first TUI for DeepSeek Harness with SSH-friendly mobile access and request-level cost tracking",
6
6
  "engines": {