@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.
- package/README.i18n.yaml +2 -2
- package/README.md +4 -2
- package/README.zh.md +6 -4
- package/dist/catalog/controller.d.ts +26 -6
- package/dist/catalog/controller.js +73 -45
- package/dist/catalog/index.d.ts +1 -0
- package/dist/cli/dsht.js +4 -1
- package/dist/cli/startup.js +30 -11
- package/dist/cli/verifier.d.ts +4 -0
- package/dist/cli/verifier.js +28 -5
- package/dist/contracts.d.ts +20 -5
- package/dist/controller/connection-streams.d.ts +22 -0
- package/dist/controller/connection-streams.js +105 -0
- package/dist/controller/connection.d.ts +14 -3
- package/dist/controller/connection.js +40 -69
- package/dist/controller/controller.d.ts +17 -233
- package/dist/controller/controller.js +108 -811
- package/dist/controller/foreground.d.ts +44 -0
- package/dist/controller/foreground.js +79 -0
- package/dist/controller/loop-coordinator.d.ts +48 -0
- package/dist/controller/loop-coordinator.js +647 -0
- package/dist/controller/verifier.d.ts +4 -0
- package/dist/cost/controller.d.ts +1 -1
- package/dist/cost/controller.js +12 -5
- package/dist/cost/scanner.js +1 -0
- package/dist/session/controller.d.ts +23 -35
- package/dist/session/controller.js +113 -363
- package/dist/session/history-reader.d.ts +32 -0
- package/dist/session/history-reader.js +170 -0
- package/dist/session/index.d.ts +1 -1
- package/dist/session/info.d.ts +3 -38
- package/dist/session/info.js +14 -1
- package/dist/session/interactions.d.ts +26 -0
- package/dist/session/interactions.js +75 -0
- package/dist/session/navigator.d.ts +47 -0
- package/dist/session/navigator.js +158 -0
- package/dist/session/prompt-backfill.d.ts +23 -0
- package/dist/session/prompt-backfill.js +88 -0
- package/dist/session/state.d.ts +20 -0
- package/dist/session/state.js +1 -0
- package/dist/session/telemetry.d.ts +15 -6
- package/dist/session/telemetry.js +44 -7
- package/dist/session/transcript.d.ts +5 -1
- package/dist/slash/index.d.ts +1 -1
- package/dist/slash/parse.d.ts +2 -126
- package/dist/slash/registry.d.ts +1 -1
- package/dist/slash/types.d.ts +126 -0
- package/dist/slash/types.js +1 -0
- package/dist/state.d.ts +5 -17
- package/dist/state.js +1 -1
- package/dist/transport/client.d.ts +4 -3
- package/dist/transport/client.js +71 -25
- package/dist/ui/app.js +86 -301
- package/dist/ui/chat/shell-view.d.ts +2 -0
- package/dist/ui/chat/shell-view.js +8 -0
- package/dist/ui/chat/use-history-view.d.ts +69 -0
- package/dist/ui/chat/use-history-view.js +123 -0
- package/dist/ui/dialogs/use-panels.d.ts +53 -0
- package/dist/ui/dialogs/use-panels.js +51 -0
- package/dist/ui/input/use-composer.d.ts +35 -0
- package/dist/ui/input/use-composer.js +109 -0
- package/dist/ui/input/use-deferred-lines.d.ts +16 -0
- package/dist/ui/input/use-deferred-lines.js +54 -0
- package/dist/ui/input/use-history-recall.d.ts +20 -0
- package/dist/ui/input/use-history-recall.js +47 -0
- package/package.json +1 -1
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { HostAccess } from '../transport/host.ts';
|
|
2
|
+
import { Transcript } from './transcript.ts';
|
|
3
|
+
import type { HistorySearch } from './types.ts';
|
|
4
|
+
interface Selection {
|
|
5
|
+
sessionId: string;
|
|
6
|
+
revision: number;
|
|
7
|
+
record: Transcript;
|
|
8
|
+
}
|
|
9
|
+
/** History reads cannot navigate, change status, or write another domain's state. */
|
|
10
|
+
export interface HistoryHost extends Pick<HostAccess, 'require' | 'online' | 'signal'> {
|
|
11
|
+
selected(): Selection;
|
|
12
|
+
current(selection: Selection): boolean;
|
|
13
|
+
owns(transcript: Transcript): boolean;
|
|
14
|
+
changed(): void;
|
|
15
|
+
}
|
|
16
|
+
export declare class HistoryReader {
|
|
17
|
+
private readonly host;
|
|
18
|
+
private readonly tasks;
|
|
19
|
+
constructor(host: HistoryHost);
|
|
20
|
+
/** A selection change cancels everything; closing one detached window cancels only its pages. */
|
|
21
|
+
cancel(source?: Transcript): void;
|
|
22
|
+
settle(): Promise<void>;
|
|
23
|
+
private read;
|
|
24
|
+
waitForHistory(signal: AbortSignal): Promise<void>;
|
|
25
|
+
older(signal?: AbortSignal, transcript?: Transcript): Promise<void>;
|
|
26
|
+
private page;
|
|
27
|
+
historyThrough(target: number | 'first', signal: AbortSignal): Promise<void>;
|
|
28
|
+
searchHistory(query: string, signal: AbortSignal): Promise<HistorySearch>;
|
|
29
|
+
/** The returned window belongs to the caller until it is installed as the reading view. */
|
|
30
|
+
historyAt(target: number, signal: AbortSignal): Promise<Transcript>;
|
|
31
|
+
}
|
|
32
|
+
export {};
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/** Selected-session history I/O, with cancellation tied to the record or window being read. */
|
|
2
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
3
|
+
import { object } from "../json.js";
|
|
4
|
+
import { toolLine } from "../text.js";
|
|
5
|
+
import { Transcript } from "./transcript.js";
|
|
6
|
+
const SEARCH_MATCH_LIMIT = 200;
|
|
7
|
+
export class HistoryReader {
|
|
8
|
+
host;
|
|
9
|
+
tasks = new Set();
|
|
10
|
+
constructor(host) {
|
|
11
|
+
this.host = host;
|
|
12
|
+
}
|
|
13
|
+
/** A selection change cancels everything; closing one detached window cancels only its pages. */
|
|
14
|
+
cancel(source) {
|
|
15
|
+
for (const task of this.tasks)
|
|
16
|
+
if (source === undefined || task.source === source)
|
|
17
|
+
task.abort.abort();
|
|
18
|
+
}
|
|
19
|
+
async settle() { await Promise.allSettled([...this.tasks].map(task => task.promise)); }
|
|
20
|
+
read(caller, source, work, discard) {
|
|
21
|
+
const abort = new AbortController();
|
|
22
|
+
const signal = AbortSignal.any([abort.signal, this.host.signal(), ...(caller ? [caller] : [])]);
|
|
23
|
+
// Register before dispatch so even a synchronous observer can cancel this operation.
|
|
24
|
+
const task = { abort, source, promise: Promise.resolve() };
|
|
25
|
+
const promise = Promise.resolve().then(async () => {
|
|
26
|
+
signal.throwIfAborted();
|
|
27
|
+
const selected = this.host.selected();
|
|
28
|
+
const check = () => {
|
|
29
|
+
signal.throwIfAborted();
|
|
30
|
+
if (!this.host.online() || !this.host.current(selected))
|
|
31
|
+
throw new Error('Session changed while reading history');
|
|
32
|
+
if (source !== undefined && !this.host.owns(source))
|
|
33
|
+
throw new Error('History window closed');
|
|
34
|
+
};
|
|
35
|
+
check();
|
|
36
|
+
const value = await work({ ...selected, client: this.host.require(), signal, check });
|
|
37
|
+
try {
|
|
38
|
+
check();
|
|
39
|
+
return value;
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
discard?.(value);
|
|
43
|
+
throw error;
|
|
44
|
+
}
|
|
45
|
+
}).finally(() => { this.tasks.delete(task); });
|
|
46
|
+
task.promise = promise;
|
|
47
|
+
this.tasks.add(task);
|
|
48
|
+
return promise;
|
|
49
|
+
}
|
|
50
|
+
waitForHistory(signal) {
|
|
51
|
+
return this.read(signal, undefined, async (context) => {
|
|
52
|
+
const deadline = Date.now() + context.client.timeoutMs;
|
|
53
|
+
while (!context.record.ready) {
|
|
54
|
+
context.check();
|
|
55
|
+
if (Date.now() >= deadline)
|
|
56
|
+
throw new Error('Session snapshot timed out');
|
|
57
|
+
await delay(20, undefined, { signal: context.signal });
|
|
58
|
+
}
|
|
59
|
+
context.check();
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
older(signal, transcript) {
|
|
63
|
+
return this.read(signal, transcript, context => this.page(context, transcript ?? context.record));
|
|
64
|
+
}
|
|
65
|
+
async page(context, transcript) {
|
|
66
|
+
context.check();
|
|
67
|
+
if (!transcript.ready || !transcript.hasMore || transcript.beforeSeq === undefined)
|
|
68
|
+
return;
|
|
69
|
+
const before = transcript.beforeSeq;
|
|
70
|
+
const result = await context.client.call('session/page', { request: {
|
|
71
|
+
address: { kind: 'session', sessionId: context.sessionId }, throughSeq: transcript.cursor,
|
|
72
|
+
beforeSeq: before, maxMessages: 80,
|
|
73
|
+
} }, context.signal);
|
|
74
|
+
context.check();
|
|
75
|
+
if (!this.host.owns(transcript))
|
|
76
|
+
throw new Error('History window closed');
|
|
77
|
+
transcript.addPage(result);
|
|
78
|
+
this.host.changed();
|
|
79
|
+
if (transcript.hasMore && (transcript.beforeSeq === undefined || transcript.beforeSeq >= before)) {
|
|
80
|
+
throw new Error('Host history page did not advance');
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
historyThrough(target, signal) {
|
|
84
|
+
return this.read(signal, undefined, async (context) => {
|
|
85
|
+
const transcript = context.record;
|
|
86
|
+
if (!transcript.ready)
|
|
87
|
+
throw new Error('Wait for the session snapshot');
|
|
88
|
+
while (transcript.hasMore && (target === 'first' || transcript.beforeSeq !== undefined && transcript.beforeSeq > target)) {
|
|
89
|
+
await this.page(context, transcript);
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
searchHistory(query, signal) {
|
|
94
|
+
return this.read(signal, undefined, async (context) => {
|
|
95
|
+
const source = context.record;
|
|
96
|
+
if (!source.ready)
|
|
97
|
+
throw new Error('Wait for the session snapshot');
|
|
98
|
+
const throughSeq = source.readThrough;
|
|
99
|
+
const needle = query.toLowerCase();
|
|
100
|
+
const result = { items: [], truncated: false };
|
|
101
|
+
const scan = (transcript) => {
|
|
102
|
+
const messages = transcript.messages;
|
|
103
|
+
for (let index = messages.length - 1; index >= 0; index--) {
|
|
104
|
+
context.check();
|
|
105
|
+
const message = messages[index];
|
|
106
|
+
if (message.role === 'Tool')
|
|
107
|
+
continue;
|
|
108
|
+
const text = message.text;
|
|
109
|
+
const match = text.toLowerCase().indexOf(needle);
|
|
110
|
+
if (match < 0)
|
|
111
|
+
continue;
|
|
112
|
+
if (result.items.length === SEARCH_MATCH_LIMIT) {
|
|
113
|
+
result.truncated = true;
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
result.items.push({ seq: message.seq, role: message.role,
|
|
117
|
+
preview: Buffer.from(toolLine(text.slice(Math.max(0, match - 40), match + needle.length + 100), 160)).toString('utf8') });
|
|
118
|
+
}
|
|
119
|
+
return true;
|
|
120
|
+
};
|
|
121
|
+
if (!scan(source))
|
|
122
|
+
return result;
|
|
123
|
+
let beforeSeq = source.beforeSeq;
|
|
124
|
+
let hasMore = source.hasMore;
|
|
125
|
+
while (hasMore && beforeSeq !== undefined) {
|
|
126
|
+
const page = object(await context.client.call('session/page', { request: {
|
|
127
|
+
address: { kind: 'session', sessionId: context.sessionId }, throughSeq, beforeSeq, maxMessages: 80,
|
|
128
|
+
} }, context.signal));
|
|
129
|
+
context.check();
|
|
130
|
+
const temporary = new Transcript();
|
|
131
|
+
try {
|
|
132
|
+
temporary.accept({ type: 'snapshot', cursor: throughSeq, assistantStream: { revision: 0 }, records: page.records, hasMore: page.hasMore });
|
|
133
|
+
const next = temporary.beforeSeq;
|
|
134
|
+
if (temporary.hasMore && (next === undefined || next >= beforeSeq))
|
|
135
|
+
throw new Error('Host history page did not advance');
|
|
136
|
+
if (!scan(temporary))
|
|
137
|
+
return result;
|
|
138
|
+
beforeSeq = next;
|
|
139
|
+
hasMore = temporary.hasMore;
|
|
140
|
+
}
|
|
141
|
+
finally {
|
|
142
|
+
temporary.dispose();
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return result;
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
/** The returned window belongs to the caller until it is installed as the reading view. */
|
|
149
|
+
historyAt(target, signal) {
|
|
150
|
+
return this.read(signal, undefined, async (context) => {
|
|
151
|
+
const throughSeq = context.record.readThrough;
|
|
152
|
+
const page = object(await context.client.call('session/page', { request: {
|
|
153
|
+
address: { kind: 'session', sessionId: context.sessionId }, throughSeq,
|
|
154
|
+
beforeSeq: target + 1, maxMessages: 80,
|
|
155
|
+
} }, context.signal));
|
|
156
|
+
context.check();
|
|
157
|
+
const window = new Transcript();
|
|
158
|
+
try {
|
|
159
|
+
window.accept({ type: 'snapshot', cursor: throughSeq, assistantStream: { revision: 0 }, records: page.records, hasMore: page.hasMore });
|
|
160
|
+
if (!window.messages.some(message => message.seq === target))
|
|
161
|
+
throw new Error('The host did not return the requested message');
|
|
162
|
+
return window;
|
|
163
|
+
}
|
|
164
|
+
catch (error) {
|
|
165
|
+
window.dispose();
|
|
166
|
+
throw error;
|
|
167
|
+
}
|
|
168
|
+
}, window => window.dispose());
|
|
169
|
+
}
|
|
170
|
+
}
|
package/dist/session/index.d.ts
CHANGED
|
@@ -13,7 +13,7 @@ export type { QueuedInput } from './telemetry.ts';
|
|
|
13
13
|
export { DEFAULT_HISTORY_LIMITS, historyLimits } from './memory.ts';
|
|
14
14
|
export type { HistoryLimits } from './memory.ts';
|
|
15
15
|
export { DEFAULT_PROMPT_LIMITS, promptText, PromptIndex, SessionInfo } from './info.ts';
|
|
16
|
-
export type { InteractionState,
|
|
16
|
+
export type { InteractionState, OptionState, PromptEntry, PromptLimits, PromptRecord, } from './info.ts';
|
|
17
17
|
export { resolveTarget } from './navigation.ts';
|
|
18
18
|
export { sessionLabel } from '../session-title.ts';
|
|
19
19
|
export { fileReferences } from './references.ts';
|
package/dist/session/info.d.ts
CHANGED
|
@@ -1,11 +1,6 @@
|
|
|
1
|
-
import { Transcript } from './transcript.ts';
|
|
2
|
-
|
|
3
|
-
import type {
|
|
4
|
-
/** A durable user prompt as the transcript reports it, before retention. */
|
|
5
|
-
export interface PromptRecord {
|
|
6
|
-
seq: number;
|
|
7
|
-
text: string;
|
|
8
|
-
}
|
|
1
|
+
import { Transcript, type PromptRecord } from './transcript.ts';
|
|
2
|
+
export type { PromptRecord } from './transcript.ts';
|
|
3
|
+
import type { AnswerValue } from './types.ts';
|
|
9
4
|
/** One retained prompt: its text, plus the durable sequence when the host also recorded it. */
|
|
10
5
|
export interface PromptEntry extends PromptRecord {
|
|
11
6
|
durable: boolean;
|
|
@@ -19,36 +14,6 @@ export interface PromptLimits {
|
|
|
19
14
|
export declare const DEFAULT_PROMPT_LIMITS: PromptLimits;
|
|
20
15
|
/** Flatten one transcript prompt into the single line the composer recalls. */
|
|
21
16
|
export declare function promptText(value: string): string;
|
|
22
|
-
/** Model dialog step: the catalog plus the provider or model being inspected. */
|
|
23
|
-
export interface ModelState {
|
|
24
|
-
catalog: ObjectValue;
|
|
25
|
-
provider?: string;
|
|
26
|
-
model?: ObjectValue;
|
|
27
|
-
}
|
|
28
|
-
/** Panels the reader opened; visibility and query text only, so the UI owns them.
|
|
29
|
-
*
|
|
30
|
-
* One container rather than one flag per call site, so the composition root has a single panel set
|
|
31
|
-
* to gate keys, reset on a session switch and close from a command. Every session panel's rows come
|
|
32
|
-
* from the selected record and its cursor is focus held by `Picker`; the one exception is `prompts`,
|
|
33
|
-
* which lists a client-global file and is therefore not derived from the record.
|
|
34
|
-
*/
|
|
35
|
-
export interface PanelState {
|
|
36
|
-
thoughts: boolean;
|
|
37
|
-
queue: boolean;
|
|
38
|
-
/** Saved shortcut prompts opened by `/prompt`; a client-global list, kept here for one registry. */
|
|
39
|
-
prompts?: boolean;
|
|
40
|
-
model?: ModelState;
|
|
41
|
-
history?: {
|
|
42
|
-
query: string;
|
|
43
|
-
contentSearch: boolean;
|
|
44
|
-
matches?: HistorySearch;
|
|
45
|
-
};
|
|
46
|
-
search?: {
|
|
47
|
-
query: string;
|
|
48
|
-
items: ObjectValue[];
|
|
49
|
-
hasMore: boolean;
|
|
50
|
-
};
|
|
51
|
-
}
|
|
52
17
|
/** Keyboard state of one pending question's options. */
|
|
53
18
|
export interface OptionState {
|
|
54
19
|
key: string;
|
package/dist/session/info.js
CHANGED
|
@@ -179,8 +179,16 @@ export class PromptIndex {
|
|
|
179
179
|
* @returns How many entries were actually retained.
|
|
180
180
|
*/
|
|
181
181
|
prepend(values) {
|
|
182
|
+
const oldest = this.oldest;
|
|
183
|
+
const seen = new Set();
|
|
182
184
|
const older = values.map(value => ({ seq: value.seq, text: promptText(value.text), durable: true }))
|
|
183
|
-
.filter(value =>
|
|
185
|
+
.filter(value => {
|
|
186
|
+
if (oldest !== undefined && value.seq >= oldest || seen.has(value.seq)
|
|
187
|
+
|| this.internal.has(value.text) || !this.retainable(value))
|
|
188
|
+
return false;
|
|
189
|
+
seen.add(value.seq);
|
|
190
|
+
return true;
|
|
191
|
+
});
|
|
184
192
|
if (!older.length)
|
|
185
193
|
return 0;
|
|
186
194
|
this.entries = [...older, ...this.entries];
|
|
@@ -241,10 +249,15 @@ export class PromptIndex {
|
|
|
241
249
|
}
|
|
242
250
|
/** Drop the oldest entries until both budgets hold; a dropped prefix stays reloadable. */
|
|
243
251
|
trim() {
|
|
252
|
+
let removed = 0;
|
|
244
253
|
while (this.entries.length > this.limits.maxEntries || this.bytes > this.limits.maxBytes) {
|
|
245
254
|
this.bytes -= this.entries.shift().text.length * 2;
|
|
255
|
+
removed++;
|
|
246
256
|
this.shed = true;
|
|
257
|
+
this.complete = false;
|
|
247
258
|
}
|
|
259
|
+
if (this.position !== undefined)
|
|
260
|
+
this.position = Math.max(0, this.position - removed);
|
|
248
261
|
}
|
|
249
262
|
}
|
|
250
263
|
/** Bytes of prompt text the process keeps across sessions before evicting the least recently used. */
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** Retained host requests and one response owner per interaction instance. */
|
|
2
|
+
import type { HostEvent } from '../transport/events.ts';
|
|
3
|
+
import type { Json } from '../json.ts';
|
|
4
|
+
import type { AnswerValue, PendingInteraction } from './types.ts';
|
|
5
|
+
export interface InteractionHost {
|
|
6
|
+
focused(): PendingInteraction | undefined;
|
|
7
|
+
admit(sessionId: string, dispatch: () => Promise<void>): Promise<void>;
|
|
8
|
+
reply(eventId: string, outcome: Json): Promise<void>;
|
|
9
|
+
changed(): void;
|
|
10
|
+
}
|
|
11
|
+
export declare class SessionInteractions {
|
|
12
|
+
private readonly host;
|
|
13
|
+
private readonly entries;
|
|
14
|
+
private readonly responding;
|
|
15
|
+
constructor(host: InteractionHost);
|
|
16
|
+
clear(): void;
|
|
17
|
+
settle(): Promise<void>;
|
|
18
|
+
pendingFor(sessionId: string | undefined): PendingInteraction[];
|
|
19
|
+
counts(): ReadonlyMap<string, number>;
|
|
20
|
+
accept(event: HostEvent): boolean;
|
|
21
|
+
cancelled(eventId: string): void;
|
|
22
|
+
answer(value: AnswerValue): Promise<void>;
|
|
23
|
+
approve(allowed: boolean): Promise<void>;
|
|
24
|
+
dismissQuestion(): Promise<void>;
|
|
25
|
+
private respond;
|
|
26
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
export class SessionInteractions {
|
|
2
|
+
host;
|
|
3
|
+
entries = new Map();
|
|
4
|
+
responding = new Map();
|
|
5
|
+
constructor(host) {
|
|
6
|
+
this.host = host;
|
|
7
|
+
}
|
|
8
|
+
clear() { this.entries.clear(); }
|
|
9
|
+
async settle() { await Promise.allSettled(this.responding.values()); }
|
|
10
|
+
pendingFor(sessionId) {
|
|
11
|
+
return [...this.entries.values()].filter(frame => frame.sessionId === sessionId);
|
|
12
|
+
}
|
|
13
|
+
counts() {
|
|
14
|
+
const counts = new Map();
|
|
15
|
+
for (const frame of this.entries.values()) {
|
|
16
|
+
if (frame.sessionId !== '')
|
|
17
|
+
counts.set(frame.sessionId, (counts.get(frame.sessionId) ?? 0) + 1);
|
|
18
|
+
}
|
|
19
|
+
return counts;
|
|
20
|
+
}
|
|
21
|
+
accept(event) {
|
|
22
|
+
if (event.kind === 'approval-request') {
|
|
23
|
+
this.entries.set(event.eventId, { kind: 'approval', eventId: event.eventId, sessionId: event.sessionId, description: event.description });
|
|
24
|
+
}
|
|
25
|
+
else if (event.kind === 'question-request') {
|
|
26
|
+
this.entries.set(event.eventId, { kind: 'question', eventId: event.eventId, sessionId: event.sessionId, questions: event.questions });
|
|
27
|
+
}
|
|
28
|
+
else
|
|
29
|
+
return false;
|
|
30
|
+
this.host.changed();
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
cancelled(eventId) { this.entries.delete(eventId); this.host.changed(); }
|
|
34
|
+
async answer(value) {
|
|
35
|
+
const pending = this.host.focused();
|
|
36
|
+
if (pending?.kind !== 'question')
|
|
37
|
+
throw new Error('No pending question');
|
|
38
|
+
await this.respond(pending, { kind: 'result', value });
|
|
39
|
+
}
|
|
40
|
+
async approve(allowed) {
|
|
41
|
+
const pending = this.host.focused();
|
|
42
|
+
if (pending?.kind !== 'approval')
|
|
43
|
+
throw new Error('No pending approval');
|
|
44
|
+
await this.respond(pending, { kind: 'result', value: allowed ? 'allowed-once' : 'rejected' });
|
|
45
|
+
}
|
|
46
|
+
async dismissQuestion() {
|
|
47
|
+
const pending = this.host.focused();
|
|
48
|
+
if (pending?.kind !== 'question')
|
|
49
|
+
throw new Error('No pending question');
|
|
50
|
+
await this.respond(pending, { kind: 'rejected', error: {
|
|
51
|
+
name: 'UserQuestionError', message: 'the user cancelled ask_user_question', code: 'ASK_CANCELLED',
|
|
52
|
+
} });
|
|
53
|
+
}
|
|
54
|
+
respond(pending, outcome) {
|
|
55
|
+
const existing = this.responding.get(pending);
|
|
56
|
+
if (existing)
|
|
57
|
+
return existing;
|
|
58
|
+
// Reserve synchronously; the next Enter joins this response instead of issuing a conflicting one.
|
|
59
|
+
const response = Promise.resolve().then(() => this.host.admit(pending.sessionId, () => {
|
|
60
|
+
// Admission may have waited. Cancellation or a replayed request invalidates the captured object.
|
|
61
|
+
if (this.entries.get(pending.eventId) !== pending)
|
|
62
|
+
throw new Error('Interaction is no longer pending');
|
|
63
|
+
return this.host.reply(pending.eventId, outcome);
|
|
64
|
+
})).then(() => {
|
|
65
|
+
if (this.entries.get(pending.eventId) === pending) {
|
|
66
|
+
this.entries.delete(pending.eventId);
|
|
67
|
+
this.host.changed();
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
this.responding.set(pending, response);
|
|
71
|
+
const release = () => { this.responding.delete(pending); };
|
|
72
|
+
void response.then(release, release);
|
|
73
|
+
return response;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/** Workspace/session navigation owns view intent independently of background list refreshes. */
|
|
2
|
+
import { type ObjectValue } from '../json.ts';
|
|
3
|
+
import type { HostAccess } from '../transport/host.ts';
|
|
4
|
+
export interface NavigationState {
|
|
5
|
+
screen: 'workspaces' | 'sessions' | 'chat' | 'path';
|
|
6
|
+
workspaces: ObjectValue[];
|
|
7
|
+
sessions: ObjectValue[];
|
|
8
|
+
showAllSessions: boolean;
|
|
9
|
+
workspaceId?: string;
|
|
10
|
+
sessionId?: string;
|
|
11
|
+
}
|
|
12
|
+
export interface NavigationHost extends HostAccess {
|
|
13
|
+
read(): Readonly<NavigationState>;
|
|
14
|
+
publish(patch: Partial<NavigationState>): void;
|
|
15
|
+
selection(): number;
|
|
16
|
+
leave(): void;
|
|
17
|
+
follow(sessionId: string): void;
|
|
18
|
+
}
|
|
19
|
+
export declare class SessionNavigator {
|
|
20
|
+
private readonly host;
|
|
21
|
+
private active?;
|
|
22
|
+
private readonly tasks;
|
|
23
|
+
private listsVersion;
|
|
24
|
+
private revision;
|
|
25
|
+
constructor(host: NavigationHost);
|
|
26
|
+
get intent(): number;
|
|
27
|
+
/** Leaving a view invalidates the request that intended to open it. */
|
|
28
|
+
cancel(): void;
|
|
29
|
+
reset(): void;
|
|
30
|
+
settle(): Promise<void>;
|
|
31
|
+
private run;
|
|
32
|
+
private load;
|
|
33
|
+
private publishLists;
|
|
34
|
+
refreshLists(): Promise<void>;
|
|
35
|
+
showPicker(screen: 'workspaces' | 'sessions', signal?: AbortSignal): Promise<void>;
|
|
36
|
+
showChat(): boolean;
|
|
37
|
+
enterPath(): void;
|
|
38
|
+
private commitWorkspace;
|
|
39
|
+
pickWorkspace(workspaceId?: string): void;
|
|
40
|
+
switchWorkspace(query?: string, signal?: AbortSignal): Promise<void>;
|
|
41
|
+
switchSession(query?: string, signal?: AbortSignal): Promise<void>;
|
|
42
|
+
createWorkspace(path: string, signal?: AbortSignal): Promise<void>;
|
|
43
|
+
createSession(signal?: AbortSignal): Promise<void>;
|
|
44
|
+
get visibleSessions(): ObjectValue[];
|
|
45
|
+
/** Longest registered path wins, with whole-segment matching for nested workspaces. */
|
|
46
|
+
adoptLocalWorkspace(directory: string): string | undefined;
|
|
47
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/** Workspace/session navigation owns view intent independently of background list refreshes. */
|
|
2
|
+
import { array, object, string } from "../json.js";
|
|
3
|
+
import { sessionLabel } from "../session-title.js";
|
|
4
|
+
import { resolveTarget } from "./navigation.js";
|
|
5
|
+
export class SessionNavigator {
|
|
6
|
+
host;
|
|
7
|
+
active;
|
|
8
|
+
tasks = new Map();
|
|
9
|
+
listsVersion = 0;
|
|
10
|
+
revision = 0;
|
|
11
|
+
constructor(host) {
|
|
12
|
+
this.host = host;
|
|
13
|
+
}
|
|
14
|
+
get intent() { return this.revision; }
|
|
15
|
+
/** Leaving a view invalidates the request that intended to open it. */
|
|
16
|
+
cancel() { this.revision++; this.active?.abort(); }
|
|
17
|
+
reset() { this.revision++; for (const abort of this.tasks.keys())
|
|
18
|
+
abort.abort(); }
|
|
19
|
+
async settle() { await Promise.allSettled(this.tasks.values()); }
|
|
20
|
+
run(caller, navigate, work) {
|
|
21
|
+
if (navigate)
|
|
22
|
+
this.cancel();
|
|
23
|
+
const abort = new AbortController();
|
|
24
|
+
if (navigate)
|
|
25
|
+
this.active = abort;
|
|
26
|
+
const selection = this.host.selection();
|
|
27
|
+
const signal = AbortSignal.any([abort.signal, this.host.signal(), ...(caller ? [caller] : [])]);
|
|
28
|
+
const task = Promise.resolve().then(async () => {
|
|
29
|
+
signal.throwIfAborted();
|
|
30
|
+
const client = this.host.require();
|
|
31
|
+
const check = () => {
|
|
32
|
+
signal.throwIfAborted();
|
|
33
|
+
if (client !== this.host.client() || navigate && selection !== this.host.selection()) {
|
|
34
|
+
throw new Error('Navigation changed while loading');
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
check();
|
|
38
|
+
return work({ client, signal, check });
|
|
39
|
+
}).finally(() => {
|
|
40
|
+
abort.abort();
|
|
41
|
+
this.tasks.delete(abort);
|
|
42
|
+
if (this.active === abort)
|
|
43
|
+
this.active = undefined;
|
|
44
|
+
});
|
|
45
|
+
this.tasks.set(abort, task);
|
|
46
|
+
return task;
|
|
47
|
+
}
|
|
48
|
+
async load(context) {
|
|
49
|
+
const version = ++this.listsVersion;
|
|
50
|
+
const [workspaces, sessions] = await Promise.all([
|
|
51
|
+
context.client.listWorkspaces(context.signal), context.client.listSessions(undefined, context.signal),
|
|
52
|
+
]);
|
|
53
|
+
context.check();
|
|
54
|
+
return { workspaces, sessions, version };
|
|
55
|
+
}
|
|
56
|
+
publishLists(lists) {
|
|
57
|
+
if (lists.version === this.listsVersion)
|
|
58
|
+
this.host.publish({ workspaces: lists.workspaces, sessions: lists.sessions });
|
|
59
|
+
}
|
|
60
|
+
refreshLists() {
|
|
61
|
+
return this.run(undefined, false, async (context) => { this.publishLists(await this.load(context)); });
|
|
62
|
+
}
|
|
63
|
+
showPicker(screen, signal) {
|
|
64
|
+
return this.run(signal, true, async (context) => {
|
|
65
|
+
const lists = await this.load(context);
|
|
66
|
+
this.publishLists(lists);
|
|
67
|
+
context.check();
|
|
68
|
+
this.host.publish({ screen });
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
showChat() {
|
|
72
|
+
if (this.host.read().sessionId === undefined)
|
|
73
|
+
return false;
|
|
74
|
+
this.cancel();
|
|
75
|
+
this.host.publish({ screen: 'chat' });
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
enterPath() { this.cancel(); this.host.publish({ screen: 'path' }); }
|
|
79
|
+
commitWorkspace(workspaceId) {
|
|
80
|
+
this.host.leave();
|
|
81
|
+
this.host.publish({ workspaceId, sessionId: undefined, showAllSessions: false, screen: 'sessions' });
|
|
82
|
+
}
|
|
83
|
+
pickWorkspace(workspaceId) { this.cancel(); this.commitWorkspace(workspaceId); }
|
|
84
|
+
switchWorkspace(query, signal) {
|
|
85
|
+
if (!query)
|
|
86
|
+
return this.showPicker('workspaces', signal);
|
|
87
|
+
return this.run(signal, true, async (context) => {
|
|
88
|
+
const lists = await this.load(context);
|
|
89
|
+
const workspace = resolveTarget(lists.workspaces, query, 'workspaceId', row => [string(row.title), string(row.path)]);
|
|
90
|
+
this.publishLists(lists);
|
|
91
|
+
context.check();
|
|
92
|
+
this.commitWorkspace(string(workspace.workspaceId));
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
switchSession(query, signal) {
|
|
96
|
+
return this.run(signal, true, async (context) => {
|
|
97
|
+
const lists = await this.load(context);
|
|
98
|
+
if (!query || query === 'all') {
|
|
99
|
+
this.publishLists(lists);
|
|
100
|
+
context.check();
|
|
101
|
+
this.host.publish({ screen: query === 'all' || this.host.read().workspaceId ? 'sessions' : 'workspaces', showAllSessions: query === 'all' });
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
const session = resolveTarget(lists.sessions, query, 'sessionId', row => [sessionLabel(row)]);
|
|
105
|
+
this.publishLists(lists);
|
|
106
|
+
context.check();
|
|
107
|
+
this.host.follow(string(session.sessionId));
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
createWorkspace(path, signal) {
|
|
112
|
+
return this.run(signal, true, async (context) => {
|
|
113
|
+
const result = object(await context.client.call('workspace/create', { request: { path } }, context.signal));
|
|
114
|
+
context.check();
|
|
115
|
+
const lists = await this.load(context);
|
|
116
|
+
this.publishLists(lists);
|
|
117
|
+
context.check();
|
|
118
|
+
this.commitWorkspace(string(object(result.workspace).workspaceId));
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
createSession(signal) {
|
|
122
|
+
return this.run(signal, true, async (context) => {
|
|
123
|
+
const workspaceId = this.host.read().workspaceId;
|
|
124
|
+
if (!workspaceId)
|
|
125
|
+
throw new Error('Select a workspace before creating a session');
|
|
126
|
+
const result = object(await context.client.call('session/create', { request: { workspaceId } }, context.signal));
|
|
127
|
+
context.check();
|
|
128
|
+
this.host.follow(string(result.sessionId));
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
get visibleSessions() {
|
|
132
|
+
const state = this.host.read();
|
|
133
|
+
const sessions = state.sessions.filter(item => !this.host.client()?.archivedSessionIds.has(string(item.sessionId)));
|
|
134
|
+
if (state.showAllSessions || !state.workspaceId)
|
|
135
|
+
return sessions;
|
|
136
|
+
const workspace = state.workspaces.find(item => item.workspaceId === state.workspaceId);
|
|
137
|
+
const ids = new Set(array(workspace?.sessionIds ?? []).map(string));
|
|
138
|
+
return sessions.filter(item => ids.has(string(item.sessionId)));
|
|
139
|
+
}
|
|
140
|
+
/** Longest registered path wins, with whole-segment matching for nested workspaces. */
|
|
141
|
+
adoptLocalWorkspace(directory) {
|
|
142
|
+
const slashed = (value) => value.replace(/\\/g, '/').replace(/\/+$/, '');
|
|
143
|
+
const target = slashed(directory);
|
|
144
|
+
if (!target)
|
|
145
|
+
return undefined;
|
|
146
|
+
let best;
|
|
147
|
+
for (const workspace of this.host.read().workspaces) {
|
|
148
|
+
const path = slashed(string(workspace.path));
|
|
149
|
+
if (!path || target !== path && !target.startsWith(`${path}/`))
|
|
150
|
+
continue;
|
|
151
|
+
if (!best || path.length > best.length)
|
|
152
|
+
best = { id: string(workspace.workspaceId), length: path.length };
|
|
153
|
+
}
|
|
154
|
+
if (best)
|
|
155
|
+
this.pickWorkspace(best.id);
|
|
156
|
+
return best?.id;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { HostAccess } from '../transport/host.ts';
|
|
2
|
+
import { Transcript } from './transcript.ts';
|
|
3
|
+
import type { PromptCache, PromptIndex } from './info.ts';
|
|
4
|
+
interface Selection {
|
|
5
|
+
sessionId: string;
|
|
6
|
+
revision: number;
|
|
7
|
+
record: Transcript;
|
|
8
|
+
prompts: PromptIndex;
|
|
9
|
+
}
|
|
10
|
+
interface BackfillHost extends Pick<HostAccess, 'require' | 'signal' | 'online'> {
|
|
11
|
+
current(selection: Selection): boolean;
|
|
12
|
+
changed(): void;
|
|
13
|
+
}
|
|
14
|
+
export declare class PromptBackfill {
|
|
15
|
+
private readonly host;
|
|
16
|
+
private readonly cache;
|
|
17
|
+
private readonly tasks;
|
|
18
|
+
constructor(host: BackfillHost, cache: PromptCache);
|
|
19
|
+
cancel(): void;
|
|
20
|
+
settle(): Promise<void>;
|
|
21
|
+
start(selection: Selection): void;
|
|
22
|
+
}
|
|
23
|
+
export {};
|