@antst/dashi 0.1.0-alpha.5

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 (50) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +25 -0
  3. package/lib/application.d.ts +43 -0
  4. package/lib/application.js +260 -0
  5. package/lib/at-trigger.d.ts +8 -0
  6. package/lib/at-trigger.js +13 -0
  7. package/lib/catalogs.d.ts +23 -0
  8. package/lib/catalogs.js +218 -0
  9. package/lib/clipboard-image.d.ts +3 -0
  10. package/lib/clipboard-image.js +74 -0
  11. package/lib/completion-trigger.d.ts +2 -0
  12. package/lib/completion-trigger.js +7 -0
  13. package/lib/external-editor.d.ts +2 -0
  14. package/lib/external-editor.js +39 -0
  15. package/lib/history-records.d.ts +4 -0
  16. package/lib/history-records.js +14 -0
  17. package/lib/image-input.d.ts +8 -0
  18. package/lib/image-input.js +37 -0
  19. package/lib/index.d.ts +4 -0
  20. package/lib/index.js +250 -0
  21. package/lib/input.d.ts +10 -0
  22. package/lib/input.js +220 -0
  23. package/lib/mouse-input.d.ts +1 -0
  24. package/lib/mouse-input.js +6 -0
  25. package/lib/presentation.d.ts +9 -0
  26. package/lib/presentation.js +54 -0
  27. package/lib/renderer.d.ts +19 -0
  28. package/lib/renderer.js +414 -0
  29. package/lib/rewind.d.ts +18 -0
  30. package/lib/rewind.js +84 -0
  31. package/lib/session-export.d.ts +3 -0
  32. package/lib/session-export.js +27 -0
  33. package/lib/session-list.d.ts +15 -0
  34. package/lib/session-list.js +65 -0
  35. package/lib/session-runtime.d.ts +35 -0
  36. package/lib/session-runtime.js +1027 -0
  37. package/lib/shell-command.d.ts +9 -0
  38. package/lib/shell-command.js +62 -0
  39. package/lib/state.d.ts +493 -0
  40. package/lib/state.js +641 -0
  41. package/lib/terminal-guard.d.ts +14 -0
  42. package/lib/terminal-guard.js +76 -0
  43. package/lib/tool-presentation.d.ts +14 -0
  44. package/lib/tool-presentation.js +156 -0
  45. package/lib/transcript.d.ts +12 -0
  46. package/lib/transcript.js +304 -0
  47. package/lib/tui-root.d.ts +20 -0
  48. package/lib/tui-root.js +25 -0
  49. package/package.json +65 -0
  50. package/validated-dsh-versions.json +3 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Anton Starikov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,25 @@
1
+ # @antst/dashi
2
+
3
+ dashi is a terminal UI for DeepSeek Harness (DSH). Install it as a bundle
4
+ via `@antst/dashi-app`, not as a standalone dependency:
5
+
6
+ Install the exact validated DSH release and start dashi:
7
+
8
+ ```sh
9
+ pnpm install @deepseek-ai/dsh@0.1.2-rc.1
10
+ dsh plugin --profile dashi add @antst/dashi-app
11
+ dsh --profile dashi
12
+ ```
13
+
14
+ Npm is unsupported for prerelease DSH because it cannot constrain a scoped
15
+ package family to one prerelease.
16
+ When upgrading DSH, remove `node_modules` and the lockfile before installing so
17
+ pnpm cannot retain stale peer-only DSH packages.
18
+ dashi validates each DSH release before adopting it, and its packages pin DSH
19
+ dependencies exactly.
20
+
21
+ Status: pre-release alpha. Interfaces and behavior may change between
22
+ alpha versions; the validated DSH version is `0.1.2-rc.1`.
23
+
24
+ Full documentation, source, and issue tracker:
25
+ https://forgejo.antst.net/ai/dashi
@@ -0,0 +1,43 @@
1
+ import { type Renderer } from './renderer.js';
2
+ import { type ActivatableOverlayValue, type DecisionAnswer, type DraftAttachment, type PendingDecision, type OverlayOption, type UiAction, type ViewState } from './state.js';
3
+ export interface ShellBindings {
4
+ readonly dispatch: (action: UiAction) => void;
5
+ readonly readState: () => ViewState;
6
+ }
7
+ export interface TerminalShellOptions {
8
+ readonly accessible?: boolean;
9
+ readonly beforeExit?: () => Promise<void>;
10
+ readonly activateOverlay?: (value: ActivatableOverlayValue) => Promise<void>;
11
+ readonly attach?: (path: string) => Promise<DraftAttachment | undefined>;
12
+ readonly complete?: (query: string, caret: number) => Promise<readonly OverlayOption[]>;
13
+ readonly createView?: (bindings: ShellBindings) => Renderer;
14
+ readonly cyclePermission?: () => Promise<void>;
15
+ readonly cwd: string;
16
+ readonly exit: (code: number) => void;
17
+ readonly externalEdit?: (text: string, cwd: string) => Promise<string>;
18
+ readonly initialAttachments?: readonly DraftAttachment[];
19
+ readonly initialCells?: ViewState['cells'];
20
+ readonly initialHasMore?: boolean;
21
+ readonly initialPrompts?: ViewState['prompts'];
22
+ readonly initialRoot?: ViewState['root'];
23
+ readonly interrupt?: () => void;
24
+ readonly inline: boolean;
25
+ readonly loadHistory?: (rootId: string) => Promise<void>;
26
+ readonly onReleased?: () => void;
27
+ readonly pasteImage?: () => Promise<DraftAttachment>;
28
+ readonly search?: (query: string, rootId: string) => Promise<void>;
29
+ readonly submit?: (text: string, mode: 'next-turn' | 'steer', attachments: readonly DraftAttachment[]) => Promise<void> | void;
30
+ readonly suspend?: () => Promise<void>;
31
+ readonly writeError?: (message: string) => void;
32
+ }
33
+ export interface TerminalShell {
34
+ cancelDecision(id: string): void;
35
+ dispatch(action: UiAction): void;
36
+ dispose(): Promise<void>;
37
+ fail(error: unknown): Promise<void>;
38
+ readState(): ViewState;
39
+ requestDecision(decision: PendingDecision): Promise<DecisionAnswer>;
40
+ start(): void;
41
+ whenIdle(): Promise<void>;
42
+ }
43
+ export declare function createTerminalShell(options: TerminalShellOptions): TerminalShell;
@@ -0,0 +1,260 @@
1
+ import { createRenderer } from './renderer.js';
2
+ import { initialViewState, reduce, } from './state.js';
3
+ import { createTerminalGuard } from './terminal-guard.js';
4
+ function suspendProcess() {
5
+ return new Promise((resolve, reject) => {
6
+ const resumed = () => { resolve(); };
7
+ process.once('SIGCONT', resumed);
8
+ try {
9
+ process.kill(process.pid, 'SIGTSTP');
10
+ }
11
+ catch (error) {
12
+ process.off('SIGCONT', resumed);
13
+ reject(error);
14
+ }
15
+ });
16
+ }
17
+ export function createTerminalShell(options) {
18
+ let state = initialViewState(options.cwd, options.inline, options.initialRoot, options.initialCells, options.initialPrompts, options.initialHasMore, options.initialAttachments);
19
+ let accepting = true;
20
+ let failed = false;
21
+ let queue = Promise.resolve();
22
+ const pending = new Map();
23
+ const bindings = {
24
+ dispatch: action => { dispatch(action); },
25
+ readState: () => state,
26
+ };
27
+ const renderer = options.createView?.(bindings) ?? createRenderer({
28
+ ...bindings, ...(options.accessible === undefined ? {} : { accessible: options.accessible }), inline: options.inline,
29
+ });
30
+ const guard = createTerminalGuard(renderer);
31
+ const writeError = options.writeError ?? (message => { process.stderr.write(message); });
32
+ let exitTimer;
33
+ let released = false;
34
+ const release = async () => {
35
+ if (exitTimer !== undefined)
36
+ clearTimeout(exitTimer);
37
+ exitTimer = undefined;
38
+ await guard.dispose();
39
+ if (released)
40
+ return;
41
+ released = true;
42
+ options.onReleased?.();
43
+ };
44
+ async function run(effect) {
45
+ switch (effect.type) {
46
+ case 'activate-overlay':
47
+ await options.activateOverlay?.(effect.value);
48
+ return;
49
+ case 'attach': {
50
+ try {
51
+ const attachment = await options.attach?.(effect.path);
52
+ if (attachment !== undefined)
53
+ dispatch({
54
+ type: 'attachment-added', attachment, source: effect.source, text: effect.text,
55
+ });
56
+ }
57
+ catch (error) {
58
+ dispatch({ type: 'runtime-error', message: error instanceof Error ? error.message : String(error) });
59
+ }
60
+ return;
61
+ }
62
+ case 'bell':
63
+ if (options.accessible !== true)
64
+ renderer.bell();
65
+ return;
66
+ case 'clipboard-image': {
67
+ try {
68
+ const attachment = await options.pasteImage?.();
69
+ if (attachment !== undefined)
70
+ dispatch({ type: 'attachment-added', attachment });
71
+ }
72
+ catch (error) {
73
+ dispatch({ type: 'runtime-error', message: error instanceof Error ? error.message : String(error) });
74
+ }
75
+ return;
76
+ }
77
+ case 'complete': {
78
+ const items = await options.complete?.(effect.query, effect.caret) ?? [];
79
+ dispatch({ type: 'completion-ready', options: items, query: effect.query });
80
+ return;
81
+ }
82
+ case 'copy': {
83
+ const result = renderer.copy(effect.text);
84
+ if (result === 'too-large')
85
+ dispatch({ type: 'runtime-error', message: 'Copy is limited to 64 KiB.' });
86
+ else if (result === 'unsupported')
87
+ dispatch({ type: 'runtime-error', message: 'This terminal does not support OSC 52 copy.' });
88
+ return;
89
+ }
90
+ case 'cycle-permission':
91
+ await options.cyclePermission?.();
92
+ return;
93
+ case 'exit':
94
+ accepting = false;
95
+ await options.beforeExit?.();
96
+ await release();
97
+ options.exit(0);
98
+ return;
99
+ case 'exit-timer':
100
+ if (exitTimer !== undefined)
101
+ clearTimeout(exitTimer);
102
+ exitTimer = effect.armed ? setTimeout(() => { dispatch({ type: 'disarm-exit' }); }, 2_000) : undefined;
103
+ return;
104
+ case 'external-edit': {
105
+ try {
106
+ let edited;
107
+ await guard.withCookedTerminal(async () => {
108
+ edited = await options.externalEdit?.(effect.text, effect.cwd);
109
+ });
110
+ if (edited !== undefined)
111
+ dispatch({ type: 'composer-set', text: edited });
112
+ }
113
+ catch (error) {
114
+ dispatch({ type: 'runtime-error', message: error instanceof Error ? error.message : String(error) });
115
+ }
116
+ return;
117
+ }
118
+ case 'interrupt':
119
+ options.interrupt?.();
120
+ return;
121
+ case 'load-history':
122
+ await options.loadHistory?.(effect.rootId);
123
+ return;
124
+ case 'redraw':
125
+ renderer.render(effect.force);
126
+ return;
127
+ case 'reject-decision': {
128
+ const settlement = pending.get(effect.id);
129
+ if (settlement === undefined)
130
+ return;
131
+ pending.delete(effect.id);
132
+ if (settlement.kind === 'approval') {
133
+ settlement.resolve(effect.reason === 'terminal' ? 'rejected' : 'cancelled');
134
+ }
135
+ else {
136
+ const error = new Error(effect.reason === 'terminal'
137
+ ? 'the terminal closed before the user answered'
138
+ : 'the question was withdrawn before the user answered');
139
+ error.name = 'UserQuestionError';
140
+ error.code = effect.reason === 'terminal' ? 'ASK_CANCELLED' : 'ASK_ABORTED';
141
+ settlement.reject(error);
142
+ }
143
+ return;
144
+ }
145
+ case 'set-composer':
146
+ renderer.setComposer(effect.text, effect.cursor);
147
+ return;
148
+ case 'search':
149
+ await options.search?.(effect.query, effect.rootId);
150
+ return;
151
+ case 'settle-decision': {
152
+ const settlement = pending.get(effect.id);
153
+ if (settlement === undefined)
154
+ return;
155
+ pending.delete(effect.id);
156
+ settlement.resolve(effect.answer);
157
+ return;
158
+ }
159
+ case 'submit':
160
+ await options.submit?.(effect.text, effect.mode, effect.attachments);
161
+ return;
162
+ case 'suspend':
163
+ await guard.withCookedTerminal(options.suspend ?? suspendProcess);
164
+ }
165
+ }
166
+ async function fail(error) {
167
+ if (failed)
168
+ return;
169
+ failed = true;
170
+ accepting = false;
171
+ const detail = error instanceof Error ? error.stack ?? error.message : String(error);
172
+ writeError(`dashi: terminal failure\n${detail}\n`);
173
+ try {
174
+ const [next, effects] = reduce(state, { type: 'terminal-lost' });
175
+ state = next;
176
+ for (const effect of effects)
177
+ await run(effect);
178
+ try {
179
+ await options.beforeExit?.();
180
+ }
181
+ catch (cleanupError) {
182
+ const cleanupDetail = cleanupError instanceof Error
183
+ ? cleanupError.stack ?? cleanupError.message
184
+ : String(cleanupError);
185
+ writeError(`dashi: cleanup failure\n${cleanupDetail}\n`);
186
+ }
187
+ await release();
188
+ }
189
+ finally {
190
+ options.exit(1);
191
+ }
192
+ }
193
+ function dispatch(action) {
194
+ if (!accepting)
195
+ return;
196
+ const [next, effects] = reduce(state, action);
197
+ state = next;
198
+ if (effects.length === 0)
199
+ return;
200
+ queue = queue.then(async () => {
201
+ for (const effect of effects)
202
+ await run(effect);
203
+ }).catch(fail);
204
+ }
205
+ async function whenIdle() {
206
+ for (;;) {
207
+ const observed = queue;
208
+ await observed;
209
+ if (queue === observed)
210
+ return;
211
+ }
212
+ }
213
+ return {
214
+ cancelDecision(id) {
215
+ dispatch({ type: 'decision-withdrawn', id });
216
+ },
217
+ dispatch,
218
+ async dispose() {
219
+ const [next, effects] = reduce(state, { type: 'terminal-lost' });
220
+ state = next;
221
+ for (const effect of effects)
222
+ await run(effect);
223
+ accepting = false;
224
+ await queue;
225
+ try {
226
+ await options.beforeExit?.();
227
+ }
228
+ finally {
229
+ await release();
230
+ }
231
+ },
232
+ fail,
233
+ readState: () => state,
234
+ requestDecision(decision) {
235
+ if (!accepting)
236
+ return Promise.reject(new Error('dashi terminal is closing'));
237
+ if (pending.has(decision.id))
238
+ return Promise.reject(new Error(`duplicate decision ${decision.id}`));
239
+ let resolve;
240
+ let reject;
241
+ const promise = new Promise((accept, decline) => {
242
+ resolve = accept;
243
+ reject = decline;
244
+ });
245
+ pending.set(decision.id, { kind: decision.kind, reject, resolve });
246
+ dispatch({ type: 'decision-enqueued', decision });
247
+ return promise;
248
+ },
249
+ start() {
250
+ try {
251
+ guard.start();
252
+ renderer.render(true);
253
+ }
254
+ catch (error) {
255
+ void fail(error);
256
+ }
257
+ },
258
+ whenIdle,
259
+ };
260
+ }
@@ -0,0 +1,8 @@
1
+ export interface AtTrigger {
2
+ readonly end: number;
3
+ readonly query: string;
4
+ readonly quoted: boolean;
5
+ readonly start: number;
6
+ }
7
+ /** Terminal copy of the @ branch in DSH ui-input-trigger core/detect. */
8
+ export declare function detectAtTrigger(draft: string, caret: number): AtTrigger | undefined;
@@ -0,0 +1,13 @@
1
+ import { activeAtToken } from '@deepseek-ai/dsh-file-reference/grammar';
2
+ /** Terminal copy of the @ branch in DSH ui-input-trigger core/detect. */
3
+ export function detectAtTrigger(draft, caret) {
4
+ const token = activeAtToken(draft, caret);
5
+ if (token === undefined)
6
+ return undefined;
7
+ return {
8
+ end: caret,
9
+ query: token.query,
10
+ quoted: token.quoted,
11
+ start: caret - token.prefix.length,
12
+ };
13
+ }
@@ -0,0 +1,23 @@
1
+ import type { Context } from '@deepseek-ai/cordis';
2
+ import type { Agent } from '@deepseek-ai/dsh-agent';
3
+ import type { AgentPreset } from '@deepseek-ai/dsh-agent-presets';
4
+ import type { ModelCatalog, ModelSelection, SessionSummary } from '@deepseek-ai/dsh-api-session-controller';
5
+ import type { SessionSearchHit } from '@deepseek-ai/dsh-session-query';
6
+ import type { Overlay, OverlayOption, RootView } from './state.js';
7
+ /** Build command and skill completion rows for one exact root. */
8
+ export declare function completionOptions(ctx: Context, agent: Agent, input: string, caret?: number, signal?: AbortSignal): Promise<readonly OverlayOption[]>;
9
+ /** Build the cold session picker without adopting any candidate. */
10
+ export declare function sessionOverlay(items: readonly SessionSummary[], currentId: string, cwd?: string): Overlay;
11
+ /** Flatten DSH's provider/model/effort catalog into one terminal picker. */
12
+ export declare function modelOverlay(catalog: ModelCatalog, selected?: ModelSelection): Overlay;
13
+ /** Build the preset picker from the native preset service. */
14
+ export declare function permissionOverlay(ctx: Context, agent: Agent): Overlay;
15
+ /** Read DSH-owned root facts for /status in one projection cut. */
16
+ export declare function statusOverlay(ctx: Context, agent: Agent, root: RootView): Overlay;
17
+ /** Show DSH's heuristic next-request composition without treating it as an exact total. */
18
+ export declare function contextOverlay(ctx: Context, agent: Agent): Overlay;
19
+ /** Build the native agent-preset picker; broken presets are visible elsewhere, not selectable here. */
20
+ export declare function agentPresetOverlay(presets: readonly AgentPreset[], current: string, blank: boolean): Overlay;
21
+ /** Build bounded same-cwd prompt-search rows from DSH query hits. */
22
+ export declare function searchOverlay(items: readonly SessionSearchHit[], rootId: string): Overlay;
23
+ export declare function currentModel(ctx: Context, agent: Agent): ModelSelection | undefined;
@@ -0,0 +1,218 @@
1
+ import { formatFileMention } from '@deepseek-ai/dsh-file-reference/grammar';
2
+ import { isUserInvocable } from '@deepseek-ai/dsh-skill';
3
+ import { detectAtTrigger } from './at-trigger.js';
4
+ import { slashCompletionQuery } from './completion-trigger.js';
5
+ import { isImagePath } from './image-input.js';
6
+ import { sessionTitle } from './session-list.js';
7
+ function matchRank(name, query) {
8
+ const candidate = name.toLocaleLowerCase();
9
+ const needle = query.toLocaleLowerCase();
10
+ return candidate.startsWith(needle) ? 0 : candidate.includes(needle) ? 1 : 2;
11
+ }
12
+ function selectedModel(ctx, agent) {
13
+ const selection = ctx.sessionProjections.snapshot(agent.session, ['modelSelection']).values.modelSelection;
14
+ return selection?.next ?? selection?.lastUsed ?? undefined;
15
+ }
16
+ /** Build command and skill completion rows for one exact root. */
17
+ export async function completionOptions(ctx, agent, input, caret = input.length, signal) {
18
+ const at = detectAtTrigger(input, caret);
19
+ if (at !== undefined) {
20
+ const service = ctx.get('fileReferences');
21
+ if (service === undefined)
22
+ return [];
23
+ const candidates = await service.list(agent, at.query, signal ?? new AbortController().signal);
24
+ return candidates.flatMap((candidate) => {
25
+ const mention = formatFileMention(candidate, at.quoted);
26
+ if (mention === undefined)
27
+ return [];
28
+ const text = `${input.slice(0, at.start)}${mention}${input.slice(at.end)}`;
29
+ const image = candidate.kind === 'file' && isImagePath(candidate.path);
30
+ return [{
31
+ detail: candidate.kind === 'directory' ? 'directory' : image ? 'attach image' : 'file',
32
+ group: 'Files',
33
+ label: `${candidate.path}${candidate.kind === 'directory' ? '/' : ''}`,
34
+ value: image
35
+ ? {
36
+ kind: 'attach',
37
+ path: candidate.path,
38
+ source: input,
39
+ text: `${input.slice(0, at.start)}${input.slice(at.end)}`,
40
+ }
41
+ : { kind: 'insert', text },
42
+ }];
43
+ });
44
+ }
45
+ const query = slashCompletionQuery(input);
46
+ if (query === undefined)
47
+ return [];
48
+ const commands = ctx.commands.list(agent)
49
+ .filter(command => matchRank(command.name, query) < 2)
50
+ .sort((left, right) => matchRank(left.name, query) - matchRank(right.name, query)
51
+ || left.name.localeCompare(right.name))
52
+ .map(command => ({
53
+ detail: command.description,
54
+ group: 'Commands',
55
+ label: `/${command.name}${command.input?.hint === undefined ? '' : ` ${command.input.hint}`}`,
56
+ ...(command.input?.hint === undefined ? { submitOnEnter: true } : {}),
57
+ value: { kind: 'insert', text: `/${command.name} ` },
58
+ }));
59
+ const skills = (await ctx.skills.list({ cwd: agent.session.header.cwd, scope: agent, signal }))
60
+ .filter(isUserInvocable)
61
+ .filter(skill => matchRank(skill.name, query) < 2)
62
+ .sort((left, right) => matchRank(left.name, query) - matchRank(right.name, query)
63
+ || left.name.localeCompare(right.name))
64
+ .map(skill => ({
65
+ detail: skill.description,
66
+ group: 'Skills',
67
+ label: `/${skill.name}`,
68
+ value: { kind: 'insert', text: `/${skill.name} ` },
69
+ }));
70
+ return [...commands, ...skills];
71
+ }
72
+ /** Build the cold session picker without adopting any candidate. */
73
+ export function sessionOverlay(items, currentId, cwd) {
74
+ return {
75
+ cursor: 0,
76
+ kind: 'list',
77
+ options: items.filter(item => item.origin === undefined && (cwd === undefined || item.cwd === cwd)).map(item => ({
78
+ active: String(item.sessionId) === currentId,
79
+ detail: `${item.cwd ?? 'cwd unavailable'} · ${new Date(item.updatedAt).toISOString()}`,
80
+ label: `${sessionTitle(item) ?? 'Untitled'} · ${String(item.sessionId)}`,
81
+ value: { kind: 'resume', sessionId: String(item.sessionId) },
82
+ })),
83
+ purpose: 'resume',
84
+ title: 'Resume session',
85
+ };
86
+ }
87
+ /** Flatten DSH's provider/model/effort catalog into one terminal picker. */
88
+ export function modelOverlay(catalog, selected) {
89
+ const options = [];
90
+ for (const group of catalog.groups) {
91
+ for (const model of group.models) {
92
+ const efforts = model.reasoning?.efforts ?? [];
93
+ if (efforts.length === 0) {
94
+ options.push({
95
+ active: selected?.provider === group.id && selected.model === model.id,
96
+ label: `${group.name} · ${model.name}`,
97
+ value: { kind: 'model', model: model.id, provider: group.id },
98
+ ...(model.description === undefined ? {} : { detail: model.description }),
99
+ });
100
+ continue;
101
+ }
102
+ for (const effort of efforts) {
103
+ options.push({
104
+ active: selected?.provider === group.id && selected.model === model.id
105
+ && (selected.reasoningEffort ?? model.reasoning?.defaultEffort) === effort.id,
106
+ label: `${group.name} · ${model.name} · ${effort.name}`,
107
+ value: { kind: 'model', effort: effort.id, model: model.id, provider: group.id },
108
+ ...(effort.description ?? model.description) === undefined
109
+ ? {} : { detail: effort.description ?? model.description },
110
+ });
111
+ }
112
+ }
113
+ }
114
+ return { cursor: Math.max(0, options.findIndex(option => option.active === true)), kind: 'list', options, purpose: 'model', title: 'Model' };
115
+ }
116
+ /** Build the preset picker from the native preset service. */
117
+ export function permissionOverlay(ctx, agent) {
118
+ const current = ctx.permissionPresets.current(agent.session);
119
+ const options = ctx.permissionPresets.names.map((preset) => {
120
+ const option = ctx.permissionPresets.optionOf(preset);
121
+ const spec = ctx.permissionPresets.resolve(preset);
122
+ return {
123
+ active: preset === current,
124
+ confirmDetail: [`Sandbox: ${spec.sandbox}`, `Approval: ${spec.approval}`],
125
+ danger: spec.approval === 'never',
126
+ label: option.name,
127
+ value: { kind: 'permission', preset },
128
+ ...(option.description === undefined ? {} : { detail: option.description }),
129
+ };
130
+ });
131
+ return {
132
+ cursor: Math.max(0, options.findIndex(option => option.active)),
133
+ kind: 'list', options, purpose: 'permission', title: 'Permission preset',
134
+ };
135
+ }
136
+ function number(value) {
137
+ return typeof value === 'number' ? value.toLocaleString('en-US') : 'unavailable';
138
+ }
139
+ /** Read DSH-owned root facts for /status in one projection cut. */
140
+ export function statusOverlay(ctx, agent, root) {
141
+ const values = ctx.sessionProjections.snapshot(agent.session, [
142
+ 'modelSelection', 'permissions', 'tokenUsage', 'contextPressure', 'contextBreakdown',
143
+ ]).values;
144
+ const model = values.modelSelection?.next ?? values.modelSelection?.lastUsed;
145
+ const usage = values.tokenUsage;
146
+ const pressure = values.contextPressure;
147
+ const breakdown = values.contextBreakdown;
148
+ return {
149
+ kind: 'info',
150
+ lines: [
151
+ `UUID: ${root.id}`,
152
+ `Title: ${root.title ?? 'Untitled'}`,
153
+ `Cwd: ${root.cwd}`,
154
+ root.parent === undefined
155
+ ? 'Lineage: root session'
156
+ : `Lineage: forked from ${root.parent} at turn ${String(root.parentTurn ?? 'unknown')}`,
157
+ `Model: ${model === null || model === undefined ? root.model : `${model.provider}/${model.model}`}`,
158
+ `Effort: ${model?.reasoningEffort ?? root.effort ?? 'default'}`,
159
+ `Permission: ${ctx.permissionPresets.current(agent.session)}`,
160
+ `Usage: input ${number(usage?.uncachedInputTokens)} · output ${number(usage?.outputTokens)} · cache read ${number(usage?.cacheReadTokens)} · cache write ${number(usage?.cacheWriteTokens)}`,
161
+ `Context: ${number(pressure?.projectedTokens)} / ${number(pressure?.contextWindow)} tokens`,
162
+ `Composition: system ${number(breakdown?.systemTokens)} · tools ${number(breakdown?.toolsTokens)} · messages ${number(breakdown?.messageTokens)}`,
163
+ ],
164
+ title: 'Status',
165
+ };
166
+ }
167
+ /** Show DSH's heuristic next-request composition without treating it as an exact total. */
168
+ export function contextOverlay(ctx, agent) {
169
+ const breakdown = ctx.sessionProjections.snapshot(agent.session, ['contextBreakdown'])
170
+ .values.contextBreakdown;
171
+ return {
172
+ kind: 'info',
173
+ lines: [
174
+ `System prompt: ${number(breakdown?.systemTokens)} tokens`,
175
+ `Tool schemas: ${number(breakdown?.toolsTokens)} tokens`,
176
+ `Messages: ${number(breakdown?.messageTokens)} tokens`,
177
+ 'These are DSH heuristic estimates for the next request, not a provider-reported total.',
178
+ ],
179
+ title: 'Context',
180
+ };
181
+ }
182
+ /** Build the native agent-preset picker; broken presets are visible elsewhere, not selectable here. */
183
+ export function agentPresetOverlay(presets, current, blank) {
184
+ const options = presets.filter(preset => preset.broken === undefined).map(preset => ({
185
+ active: preset.id === current,
186
+ label: preset.name ?? preset.id,
187
+ value: { kind: 'agent-preset', preset: preset.id },
188
+ ...(preset.description === undefined ? {} : { detail: preset.description }),
189
+ }));
190
+ return {
191
+ cursor: Math.max(0, options.findIndex(option => option.active)),
192
+ kind: 'list',
193
+ notice: blank ? 'The current session is blank.' : 'Choosing a preset starts a new session.',
194
+ options,
195
+ purpose: 'agents',
196
+ title: 'Agent preset',
197
+ };
198
+ }
199
+ /** Build bounded same-cwd prompt-search rows from DSH query hits. */
200
+ export function searchOverlay(items, rootId) {
201
+ return {
202
+ cursor: 0,
203
+ kind: 'list',
204
+ options: items.map(item => ({
205
+ detail: `${String(item.header.id)} · ${item.header.cwd ?? 'cwd unavailable'}`,
206
+ label: item.bestMatch.snippet.replaceAll(/\s+/gu, ' ').trim(),
207
+ value: {
208
+ kind: 'search-result', rootId, seq: item.bestMatch.seq,
209
+ sessionId: String(item.header.id),
210
+ },
211
+ })),
212
+ purpose: 'search',
213
+ title: 'Prompt search',
214
+ };
215
+ }
216
+ export function currentModel(ctx, agent) {
217
+ return selectedModel(ctx, agent);
218
+ }
@@ -0,0 +1,3 @@
1
+ import type { DraftAttachment } from './state.js';
2
+ /** Read a PNG from an installed platform clipboard helper. */
3
+ export declare function readClipboardImage(platform?: NodeJS.Platform, path?: string): Promise<DraftAttachment>;