@itookit/dsht 0.3.7 → 0.5.1

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 (132) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +31 -11
  3. package/README.zh.md +31 -11
  4. package/dist/cli/dsht.js +207 -19
  5. package/dist/cli/startup.d.ts +40 -0
  6. package/dist/cli/startup.js +295 -0
  7. package/dist/cli/trace-summary.d.ts +78 -0
  8. package/dist/cli/trace-summary.js +241 -0
  9. package/dist/cli/verifier.d.ts +60 -0
  10. package/dist/cli/verifier.js +242 -0
  11. package/dist/contracts.d.ts +344 -0
  12. package/dist/contracts.js +1 -0
  13. package/dist/controller/commands.d.ts +47 -0
  14. package/dist/controller/commands.js +322 -0
  15. package/dist/controller/connection.d.ts +11 -29
  16. package/dist/controller/connection.js +26 -60
  17. package/dist/controller/controller.d.ts +619 -164
  18. package/dist/controller/controller.js +1420 -141
  19. package/dist/controller/index.d.ts +8 -1
  20. package/dist/controller/index.js +5 -0
  21. package/dist/controller/loop-contract.d.ts +136 -0
  22. package/dist/controller/loop-contract.js +308 -0
  23. package/dist/controller/loop-prompts-schema.d.ts +56 -0
  24. package/dist/controller/loop-prompts-schema.js +144 -0
  25. package/dist/controller/loop-prompts.d.ts +55 -0
  26. package/dist/controller/loop-prompts.generated.d.ts +104 -0
  27. package/dist/controller/loop-prompts.generated.js +185 -0
  28. package/dist/controller/loop-prompts.js +104 -0
  29. package/dist/controller/loop-protocols.d.ts +39 -0
  30. package/dist/controller/loop-protocols.js +115 -0
  31. package/dist/controller/loop.d.ts +275 -0
  32. package/dist/controller/loop.js +378 -0
  33. package/dist/controller/prompts.d.ts +54 -0
  34. package/dist/controller/prompts.js +162 -0
  35. package/dist/controller/trace-log.d.ts +45 -0
  36. package/dist/controller/trace-log.js +144 -0
  37. package/dist/controller/verifier.d.ts +126 -0
  38. package/dist/controller/verifier.js +75 -0
  39. package/dist/cost/index.d.ts +1 -1
  40. package/dist/cost/index.js +1 -1
  41. package/dist/cost/ledger.d.ts +0 -1
  42. package/dist/cost/ledger.js +0 -1
  43. package/dist/json.d.ts +18 -0
  44. package/dist/json.js +19 -0
  45. package/dist/references.d.ts +25 -0
  46. package/dist/references.js +26 -0
  47. package/dist/session/connection-view.d.ts +2 -11
  48. package/dist/session/controller.d.ts +82 -72
  49. package/dist/session/controller.js +211 -209
  50. package/dist/session/history.d.ts +9 -1
  51. package/dist/session/history.js +1 -9
  52. package/dist/session/index.d.ts +9 -4
  53. package/dist/session/index.js +7 -3
  54. package/dist/session/info.d.ts +25 -52
  55. package/dist/session/info.js +39 -25
  56. package/dist/session/markdown.js +1 -1
  57. package/dist/session/math.js +1 -1
  58. package/dist/session/mutation-gate.d.ts +51 -0
  59. package/dist/session/mutation-gate.js +73 -0
  60. package/dist/session/navigation.d.ts +2 -89
  61. package/dist/session/navigation.js +2 -129
  62. package/dist/session/peek.d.ts +38 -0
  63. package/dist/session/peek.js +103 -0
  64. package/dist/session/references.d.ts +2 -20
  65. package/dist/session/references.js +1 -26
  66. package/dist/session/runtime.d.ts +26 -0
  67. package/dist/session/runtime.js +28 -0
  68. package/dist/session/telemetry.d.ts +12 -13
  69. package/dist/session/telemetry.js +27 -58
  70. package/dist/session/transcript.d.ts +0 -6
  71. package/dist/session/transcript.js +2 -15
  72. package/dist/session/types.d.ts +25 -0
  73. package/dist/session/types.js +0 -1
  74. package/dist/session-title.d.ts +9 -0
  75. package/dist/session-title.js +21 -0
  76. package/dist/shell/controller.d.ts +97 -0
  77. package/dist/shell/controller.js +158 -0
  78. package/dist/shell/index.d.ts +5 -0
  79. package/dist/shell/index.js +3 -0
  80. package/dist/shell/runner.d.ts +38 -0
  81. package/dist/shell/runner.js +147 -0
  82. package/dist/slash/index.d.ts +10 -0
  83. package/dist/slash/index.js +7 -0
  84. package/dist/slash/parse.d.ts +166 -0
  85. package/dist/slash/parse.js +259 -0
  86. package/dist/slash/pipeline.d.ts +140 -0
  87. package/dist/slash/pipeline.js +115 -0
  88. package/dist/slash/registry.d.ts +88 -0
  89. package/dist/slash/registry.js +177 -0
  90. package/dist/state.d.ts +14 -4
  91. package/dist/state.js +3 -2
  92. package/dist/text.d.ts +28 -0
  93. package/dist/text.js +55 -0
  94. package/dist/transport/events.d.ts +104 -0
  95. package/dist/transport/events.js +149 -0
  96. package/dist/transport/wire.d.ts +9 -17
  97. package/dist/transport/wire.js +2 -27
  98. package/dist/ui/app.js +865 -431
  99. package/dist/ui/chat/header.js +1 -1
  100. package/dist/ui/chat/history-view.d.ts +1 -1
  101. package/dist/ui/chat/history-view.js +1 -1
  102. package/dist/ui/chat/loop-status.d.ts +11 -0
  103. package/dist/ui/chat/loop-status.js +28 -0
  104. package/dist/ui/chat/navigation-model.d.ts +86 -0
  105. package/dist/ui/chat/navigation-model.js +107 -0
  106. package/dist/ui/chat/shell-view.d.ts +47 -0
  107. package/dist/ui/chat/shell-view.js +145 -0
  108. package/dist/ui/chat/status.d.ts +47 -3
  109. package/dist/ui/chat/status.js +65 -50
  110. package/dist/ui/chat/viewport.d.ts +1 -1
  111. package/dist/ui/dialogs/cost.d.ts +21 -4
  112. package/dist/ui/dialogs/cost.js +7 -12
  113. package/dist/ui/dialogs/index.d.ts +22 -5
  114. package/dist/ui/dialogs/index.js +19 -3
  115. package/dist/ui/dialogs/loop.d.ts +43 -0
  116. package/dist/ui/dialogs/loop.js +224 -0
  117. package/dist/ui/dialogs/peek.d.ts +25 -0
  118. package/dist/ui/dialogs/peek.js +35 -0
  119. package/dist/ui/dialogs/picker.d.ts +2 -0
  120. package/dist/ui/dialogs/picker.js +4 -2
  121. package/dist/ui/input/mouse.d.ts +12 -2
  122. package/dist/ui/input/mouse.js +20 -7
  123. package/dist/ui/input/references.d.ts +1 -1
  124. package/dist/ui/status/model.d.ts +7 -0
  125. package/dist/ui/status/model.js +5 -0
  126. package/dist/ui/theme/index.d.ts +6 -1
  127. package/dist/ui/theme/index.js +2 -1
  128. package/package.json +6 -4
  129. package/dist/ui/commands/parse.d.ts +0 -99
  130. package/dist/ui/commands/parse.js +0 -126
  131. package/dist/ui/commands/registry.d.ts +0 -33
  132. package/dist/ui/commands/registry.js +0 -73
@@ -5,9 +5,8 @@ import { memo, useEffect, useState } from 'react';
5
5
  import { Box, Text, useStdout } from 'ink';
6
6
  import wrapAnsi from 'wrap-ansi';
7
7
  import stringWidth from 'string-width';
8
- import { costText } from "../../cost/index.js";
9
- import { toolLine } from "../../session/transcript.js";
10
- import { safeText } from "../../transport/wire.js";
8
+ import { toolLine } from "../../text.js";
9
+ import { safeText } from "../../text.js";
11
10
  /** Format elapsed wall time, clamping clock skew instead of displaying negative durations.
12
11
  * @param milliseconds - Elapsed duration.
13
12
  * @returns Minute/second display, with hours when needed.
@@ -215,32 +214,33 @@ function measure(row) {
215
214
  return row.reduce((sum, segment) => sum + stringWidth(segment.text), 0);
216
215
  }
217
216
  /** Render a live clock and selected-session metadata; the timer belongs to this mounted bar. */
218
- export const StatusBar = memo(function StatusBar({ controller, expanded = false, width, scroll = 0, pageSize, onScroll, onOverflow, onRows, pauseReason }) {
217
+ export const StatusBar = memo(function StatusBar({ source, expanded = false, width, scroll = 0, pageSize, onScroll, onOverflow, onRows, pauseReason }) {
219
218
  const paused = pauseReason !== undefined;
220
219
  const theme = useTheme();
221
220
  const { stdout } = useStdout();
222
221
  const [now, setNow] = useState(Date.now);
223
222
  const [reported, setReported] = useState(1);
224
- const running = controller.running;
225
- const since = controller.workingSince;
223
+ const running = source.running;
224
+ const activity = source.activity;
225
+ // The controller already merged a turn and a loop into one answer; the bar only draws it.
226
+ const busy = activity !== undefined;
227
+ const since = activity?.kind === 'turn' ? activity.since : activity?.kind === 'loop' ? activity.startedAt : undefined;
226
228
  useEffect(() => {
227
229
  setNow(Date.now());
228
230
  if (paused)
229
231
  return;
230
232
  // An idle bar still re-reads the clock, so a day rollover reaches the cost it reports without
231
- // waiting for an unrelated render; a running bar keeps its per-second clock.
232
- const timer = setInterval(() => setNow(Date.now()), running ? 1000 : 60_000);
233
+ // waiting for an unrelated render; a busy bar keeps its per-second clock.
234
+ const timer = setInterval(() => setNow(Date.now()), busy ? 1000 : 60_000);
233
235
  return () => clearInterval(timer);
234
- }, [running, since, paused]);
235
- const state = controller.state;
236
- const workspace = state.workspaces.find(item => item.workspaceId === state.workspaceId);
237
- const view = controller.telemetry.view(state.sessionId);
238
- const costs = controller.costs;
239
- const sessionCost = controller.sessionCostText;
240
- const todayCost = costs ? costText(costs.today()) : '?';
236
+ }, [busy, since, paused]);
237
+ const state = source;
238
+ const view = { values: source.values, queued: source.queued, jobs: source.jobs };
239
+ const costs = source.cost;
240
+ const sessionCost = source.cost?.sessionText ?? '?';
241
241
  // `*` belongs to costText alone; incomplete coverage is a separate degradation, reported by `!`.
242
- const coverage = costs?.coverage ?? 'complete';
243
- const label = workspace ? `${workspace.title} · ${workspace.path}` : 'none selected';
242
+ const coverage = source.cost?.coverage ?? 'complete';
243
+ const label = source.workspaceLabel;
244
244
  if (!expanded) {
245
245
  const selection = record(view.values.modelSelection);
246
246
  const route = record(running ? selection.lastUsed ?? selection.next ?? state.defaultModel : selection.next ?? selection.lastUsed ?? state.defaultModel);
@@ -258,29 +258,38 @@ export const StatusBar = memo(function StatusBar({ controller, expanded = false,
258
258
  const billed = buckets[0] === undefined || buckets[2] === undefined ? undefined : buckets[0] + buckets[2] + (buckets[3] ?? 0);
259
259
  const hit = cacheHitText(buckets[2], billed);
260
260
  const compactCount = (value) => value === undefined ? '?' : compactNumber.format(value);
261
- const phase = state.session.record.livePhase;
262
- const clock = running && since !== undefined ? ` ${clockText(now - since)}` : '';
261
+ const phase = source.livePhase;
262
+ const clock = busy && since !== undefined ? ` ${clockText(now - since)}` : '';
263
263
  const phaseLabel = phase === undefined ? undefined
264
264
  : phase.kind === 'tool' ? `${phase.name ?? 'tool'} ${phaseText(now - phase.startedAt)}`
265
265
  : `${phase.kind === 'thinking' ? 'think' : 'write'} ${phaseText(now - phase.startedAt)}`;
266
+ // A host turn's phase wins; without one, the controller's loop activity names what the client is
267
+ // doing, so the bar never reports an idle session while a review is in flight.
268
+ const phaseSegment = !busy ? undefined
269
+ : phaseLabel !== undefined ? { text: phaseLabel }
270
+ : activity?.kind === 'loop' ? { text: `${activity.activity} ${activity.step}/${activity.total}` }
271
+ : activity?.kind === 'paused' ? { text: 'needs you' }
272
+ : undefined;
266
273
  // The state token reports a fact and never guesses: a paused clock is named, offline and errors
267
274
  // take the token over, and an unknown phase simply leaves the phase group empty. An answer this
268
275
  // client still owes outranks the paused reason, because that reason is only why the clock stopped.
269
- const stateToken = !state.online
276
+ const stateToken = !source.online
270
277
  ? { text: '! Offline', color: theme.status.offline }
271
- : state.controlError || state.modelError
278
+ : source.controlError || source.modelError
272
279
  ? { text: '⚠ Error', color: theme.status.warning }
273
- : state.pending.length > 0
280
+ : source.pendingCount > 0
274
281
  ? { text: '? Needs you', color: theme.status.critical }
275
282
  : pauseReason !== undefined
276
283
  ? { text: `⏸ ${pauseReason}${clock}`, color: theme.colors.muted }
277
- : running ? { text: `◐${clock}`, color: theme.status.working } : { text: '● Ready', color: theme.status.ready };
284
+ : activity?.kind === 'paused' ? { text: '⏸ needs you', color: theme.status.critical }
285
+ : busy ? { text: `◐${clock}`, color: theme.status.working } : { text: '● Ready', color: theme.status.ready };
278
286
  // One marker covers both scopes, because either an unpriceable record or a scan that has not
279
287
  // covered every session makes the pair inexact as a reading.
280
288
  const inexact = coverage !== 'complete';
281
289
  const money = (session, today) => `¥: ${session === undefined ? '?' : session.amount.toFixed(2)}(${today.amount.toFixed(2)})${session?.unknown || today.unknown || inexact ? '*' : ''}`;
290
+ // Read here rather than from a snapshot, so the bar's own clock drives a day rollover.
282
291
  const todayTotal = costs?.today();
283
- const sessionTotal = costs !== undefined && costs.hasSession(state.sessionId) ? costs.total(state.sessionId) : undefined;
292
+ const sessionTotal = costs?.session();
284
293
  const contextColor = percent === undefined ? theme.status.usage
285
294
  : percent >= 95 ? theme.status.critical : percent >= 80 ? theme.status.warning : theme.status.context;
286
295
  // Ten cells resolve context to tenths, the same resolution the percentage beside them reports.
@@ -288,10 +297,11 @@ export const StatusBar = memo(function StatusBar({ controller, expanded = false,
288
297
  const groups = {
289
298
  state: stateToken,
290
299
  // A paused bar keeps the phase: the reason already says why the clock stopped, and dropping the
291
- // running tool would leave the one question this bar exists to answer unanswered. A ready bar
292
- // has none: the transcript keeps the last event it saw, and only the host knows the turn ended.
293
- ...(phaseLabel === undefined || !running ? {} : { phase: { text: phaseLabel } }),
294
- ...(running && pauseReason === undefined ? { stop: { text: '^C' } } : {}),
300
+ // running tool would leave the one question this bar exists to answer unanswered. A bar with no
301
+ // work of its own has none: the transcript keeps the last event it saw, and only the host knows
302
+ // the turn ended.
303
+ ...(phaseSegment === undefined ? {} : { phase: phaseSegment }),
304
+ ...(busy && pauseReason === undefined ? { stop: { text: '^C' } } : {}),
295
305
  ...(todayTotal === undefined ? {} : { cost: { text: money(sessionTotal, todayTotal), color: theme.status.cost } }),
296
306
  ...(percent === undefined ? {} : {
297
307
  context: { text: `ctx ${percent}%`, color: contextColor },
@@ -310,26 +320,27 @@ export const StatusBar = memo(function StatusBar({ controller, expanded = false,
310
320
  }
311
321
  return _jsx(Box, { flexDirection: "column", children: rows.map((row, index) => _jsx(Text, { wrap: "truncate-end", children: row.map((segment, position) => _jsx(Text, { color: segment.color ?? theme.colors.muted, bold: index === 0 && position === 0, children: segment.text }, position)) }, index)) });
312
322
  }
313
- return _jsx(StatusDetails, { controller: controller, theme: theme, width: width, now: now, scroll: scroll, pageSize: pageSize, onScroll: onScroll, onOverflow: onOverflow });
323
+ return _jsx(StatusDetails, { source: source, theme: theme, width: width, now: now, scroll: scroll, pageSize: pageSize, onScroll: onScroll, onOverflow: onOverflow });
314
324
  });
315
325
  /** Expanded detail panel.
316
326
  *
317
327
  * It is a separate component because it is the only branch that scrolls, and a hook behind the
318
328
  * collapsed branch's early return would change the hook order between the two states.
319
329
  */
320
- const StatusDetails = memo(function StatusDetails({ controller, theme, width, now, scroll, pageSize, onScroll, onOverflow }) {
330
+ const StatusDetails = memo(function StatusDetails({ source, theme, width, now, scroll, pageSize, onScroll, onOverflow }) {
321
331
  const { stdout } = useStdout();
322
- const state = controller.state;
323
- const running = controller.running;
324
- const since = controller.workingSince;
325
- const workspace = state.workspaces.find(item => item.workspaceId === state.workspaceId);
326
- const view = controller.telemetry.view(state.sessionId);
327
- const costs = controller.costs;
328
- const sessionCost = controller.sessionCostText;
329
- const todayCost = costs ? costText(costs.today()) : '?';
332
+ const state = source;
333
+ const running = source.running;
334
+ const activity = source.activity;
335
+ const busy = activity !== undefined;
336
+ const since = activity?.kind === 'turn' ? activity.since : activity?.kind === 'loop' ? activity.startedAt : undefined;
337
+ const view = { values: source.values, queued: source.queued, jobs: source.jobs };
338
+ const costs = source.cost;
339
+ const sessionCost = source.cost?.sessionText ?? '?';
340
+ const todayCost = source.cost?.today().text ?? '?';
330
341
  // `*` belongs to costText alone; incomplete coverage is a separate degradation, reported by `!`.
331
- const coverage = costs?.coverage ?? 'complete';
332
- const label = workspace ? `${workspace.title} · ${workspace.path}` : 'none selected';
342
+ const coverage = source.cost?.coverage ?? 'complete';
343
+ const label = source.workspaceLabel;
333
344
  // Every detail row wraps to the terminal width, so a narrow terminal loses nothing; lines that
334
345
  // still do not fit are scrolled rather than dropped, because the panel shares the screen height.
335
346
  // Rows are merged and labelled compactly so a normal terminal shows every detail on one screen;
@@ -337,23 +348,27 @@ const StatusDetails = memo(function StatusDetails({ controller, theme, width, no
337
348
  const turns = count(numeric(record(view.values.sessionStats).turns));
338
349
  const duration = since === undefined ? 'unknown duration' : elapsedTime(now - since);
339
350
  const detail = [
340
- { key: 'activity', color: state.pending.length > 0 ? theme.status.critical : running ? theme.colors.context : theme.colors.muted, text: state.pending.length > 0
351
+ { key: 'activity', color: source.pendingCount > 0 ? theme.status.critical : busy ? theme.colors.context : theme.colors.muted, text: source.pendingCount > 0
341
352
  ? '? Needs you · answer the request above to continue'
342
353
  : running
343
- ? `◐ Working · ${duration}${state.session.record.activeTurnStartedAt === undefined ? ' (observed)' : ''} · Ctrl+C Stop`
344
- : '● Ready · Ctrl+C exit' },
345
- { key: 'host', text: `${safeText(controller.base)} · ${safeText(state.status)}${!state.online ? ' · offline, last known status' : ''}` },
346
- ...state.sessionId
347
- ? [{ key: 'session', text: `Session ${safeText(state.sessionId)}${controller.sessionMode ? ` · ${safeText(controller.sessionMode)}` : ''}` }] : [],
354
+ ? `◐ Working · ${duration}${source.activeTurnStartedAt === undefined ? ' (observed)' : ''} · Ctrl+C Stop`
355
+ : activity?.kind === 'loop'
356
+ ? `◐ ${activity.activity} ${activity.step}/${activity.total} · ${duration} · Ctrl+C Stop`
357
+ : activity?.kind === 'paused'
358
+ ? `⏸ ${activity.title} · needs you · /loop answer <text> or /loop abort`
359
+ : '● Ready · Ctrl+C exit' },
360
+ { key: 'host', text: `${safeText(source.host)} · ${safeText(state.status)}${!source.online ? ' · offline, last known status' : ''}` },
361
+ ...source.sessionId
362
+ ? [{ key: 'session', text: `Session ${safeText(source.sessionId)}${source.sessionMode ? ` · ${safeText(source.sessionMode)}` : ''}` }] : [],
348
363
  { key: 'workspace', text: `Workspace ${safeText(label)}` },
349
- ...metricLines(view.values, state.defaultModel, running).map((line, index) => ({ key: `metric-${index}`, text: safeText(line), dim: true })),
364
+ ...metricLines(view.values, source.defaultModel, running).map((line, index) => ({ key: `metric-${index}`, text: safeText(line), dim: true })),
350
365
  ...costs ? [{ key: 'cost', text: `Cost ${sessionCost} session · ${todayCost} today · ${turns} turns`, dim: true }] : [],
351
366
  { key: 'queued', text: `Queued ${count(view.queued)} · Jobs ${count(view.jobs)}${costs ? '' : ` · ${turns} turns`}`, dim: true },
352
367
  ...costs && coverage === 'partial'
353
368
  ? [{ key: 'coverage', color: theme.colors.context, text: `Cost coverage incomplete: ${costs.error ? safeText(costs.error) : 'no complete scan yet'}` }] : [],
354
- ...state.controlError ? [{ key: 'control-error', color: theme.colors.context, text: safeText(state.controlError) }] : [],
355
- ...state.presetError ? [{ key: 'preset-error', color: theme.colors.context, text: `Preset names unavailable: ${safeText(state.presetError)}` }] : [],
356
- ...state.modelError ? [{ key: 'model-error', color: theme.colors.context, text: `Model catalog unavailable: ${safeText(state.modelError)}` }] : [],
369
+ ...source.controlError ? [{ key: 'control-error', color: theme.colors.context, text: safeText(source.controlError) }] : [],
370
+ ...source.presetError ? [{ key: 'preset-error', color: theme.colors.context, text: `Preset names unavailable: ${safeText(source.presetError)}` }] : [],
371
+ ...source.modelError ? [{ key: 'model-error', color: theme.colors.context, text: `Model catalog unavailable: ${safeText(source.modelError)}` }] : [],
357
372
  ];
358
373
  // Border and horizontal padding take four columns, so a detail row wraps inside what is left.
359
374
  const inner = Math.max(1, (width ?? Math.max(3, (stdout.columns ?? 80) - 2)) - 4);
@@ -1,7 +1,7 @@
1
1
  /** Measured conversation viewport: only visible rows become React nodes. */
2
2
  import { type DOMElement } from 'ink';
3
3
  import type { RefObject } from 'react';
4
- import type { HistoryRow } from '../../session/history.ts';
4
+ import type { HistoryRow } from '../../contracts.ts';
5
5
  /** Visible conversation rows plus the scroll hint.
6
6
  * @param props - Rows, hint state, freeze identity and the measured box.
7
7
  * @returns The scrollable conversation area.
@@ -1,8 +1,25 @@
1
- import type { Controller } from '../../controller/index.ts';
1
+ import type { Coverage } from '../../contracts.ts';
2
+ /** One formatted cost line: what to show, and how exact it is. */
3
+ export interface CostLine {
4
+ text: string;
5
+ unknown: number;
6
+ records: number;
7
+ }
8
+ /** The billing panel's plain input, assembled by the composition root. */
9
+ export interface CostSource {
10
+ session?: CostLine;
11
+ today: CostLine;
12
+ scanning: boolean;
13
+ coverage: Coverage;
14
+ scannedAt?: number;
15
+ customPrices: boolean;
16
+ error?: string;
17
+ missing: readonly string[];
18
+ }
2
19
  /** Render cached totals while the independent HTTP cost scan refreshes.
3
- * @param controller - Selected session and origin-scoped cost ledger.
20
+ * @param source - Plain billing summary, absent when this run has no ledger.
4
21
  * @returns Billing panel, including unpriced models and refresh errors.
5
22
  */
6
- export declare function CostPanel({ controller }: {
7
- controller: Controller;
23
+ export declare function CostPanel({ source }: {
24
+ source?: CostSource;
8
25
  }): import("react").JSX.Element;
@@ -2,24 +2,19 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  /** Billing estimates with explicit coverage, calendar and unknown-price indicators. */
3
3
  import { useTheme } from "../theme/index.js";
4
4
  import { Box, Text } from 'ink';
5
- import { costText } from "../../cost/index.js";
6
- import { safeText } from "../../transport/wire.js";
5
+ import { safeText } from "../../text.js";
7
6
  /** Render cached totals while the independent HTTP cost scan refreshes.
8
- * @param controller - Selected session and origin-scoped cost ledger.
7
+ * @param source - Plain billing summary, absent when this run has no ledger.
9
8
  * @returns Billing panel, including unpriced models and refresh errors.
10
9
  */
11
- export function CostPanel({ controller }) {
10
+ export function CostPanel({ source }) {
12
11
  const theme = useTheme();
13
- const costs = controller.costs;
12
+ const costs = source;
14
13
  if (!costs)
15
14
  return _jsx(Text, { children: "Cost tracking is unavailable" });
16
- const id = controller.state.sessionId;
17
- const rows = [
18
- ['Session', id && costs.hasSession(id) ? costs.total(id) : undefined],
19
- ['Today', costs.today()],
20
- ];
21
- return _jsxs(Box, { flexDirection: "column", borderStyle: "single", paddingX: 1, children: [_jsx(Text, { bold: true, children: "Cost \u00B7 CNY estimate \u00B7 Asia/Shanghai \u00B7 /cost closes" }), rows.map(([label, total]) => _jsxs(Text, { children: [label, ": ", total ? `${costText(total)} · ${total.unknown} unpriced / ${total.records} requests` : '?'] }, label)), _jsx(Text, { dimColor: true, children: costs.scanning ? 'Refreshing all visible sessions…'
15
+ const rows = [['Session', costs.session], ['Today', costs.today]];
16
+ return _jsxs(Box, { flexDirection: "column", borderStyle: "single", paddingX: 1, children: [_jsx(Text, { bold: true, children: "Cost \u00B7 CNY estimate \u00B7 Asia/Shanghai \u00B7 /cost closes" }), rows.map(([label, total]) => _jsxs(Text, { children: [label, ": ", total ? `${total.text} · ${total.unknown} unpriced / ${total.records} requests` : '?'] }, label)), _jsx(Text, { dimColor: true, children: costs.scanning ? 'Refreshing all visible sessions…'
22
17
  : costs.coverage === 'partial' ? 'Partial totals · awaiting a complete scan'
23
18
  : costs.scannedAt ? `Last refresh: ${new Date(costs.scannedAt).toISOString()}`
24
- : 'Cached totals from the previous run' }), costs.customPrices && _jsx(Text, { color: theme.colors.context, children: "Rates come from prices.json, not the shipped table." }), _jsx(Text, { dimColor: true, children: "Recorded settlement time determines tariff; * means a subtotal is not exact. Provider invoices are authoritative." }), costs.error && _jsxs(Text, { color: theme.colors.context, children: ["Partial totals: ", safeText(costs.error)] }), costs.missing().slice(0, 6).map(reason => _jsx(Text, { color: theme.colors.context, children: safeText(reason) }, reason))] });
19
+ : 'Cached totals from the previous run' }), costs.customPrices && _jsx(Text, { color: theme.colors.context, children: "Rates come from prices.json, not the shipped table." }), _jsx(Text, { dimColor: true, children: "Recorded settlement time determines tariff; * means a subtotal is not exact. Provider invoices are authoritative." }), costs.error && _jsxs(Text, { color: theme.colors.context, children: ["Partial totals: ", safeText(costs.error)] }), costs.missing.slice(0, 6).map(reason => _jsx(Text, { color: theme.colors.context, children: safeText(reason) }, reason))] });
25
20
  }
@@ -1,7 +1,7 @@
1
- import { type ObjectValue } from '../../transport/wire.ts';
2
- import { type Message } from '../../session/transcript.ts';
3
- import type { HistorySearch, RemovalTarget } from '../../session/types.ts';
4
- import type { QueuedInput } from '../../session/telemetry.ts';
1
+ import { type ObjectValue } from '../../json.ts';
2
+ import { type Message, type SavedPrompt } from '../../contracts.ts';
3
+ import type { HistorySearch, RemovalTarget } from '../../contracts.ts';
4
+ import type { QueuedInput } from '../../contracts.ts';
5
5
  import { type Choice } from './picker.tsx';
6
6
  /** Pending host input with explicit removal.
7
7
  * @param props - Queue contents, geometry and availability.
@@ -16,6 +16,23 @@ export declare function QueueDialog({ queued, rows, width, unavailable, enabled,
16
16
  canSelect(): boolean;
17
17
  onRemove(id: string): void;
18
18
  }): import("react").JSX.Element;
19
+ /** Operator-saved shortcut prompts with choose, edit and delete.
20
+ *
21
+ * Enter hands the text to the composer rather than sending it, so the reader can adjust it first.
22
+ * @param props - Saved entries, their load error, geometry and the three row actions.
23
+ * @returns The saved-prompt picker.
24
+ */
25
+ export declare function PromptsDialog({ identity, prompts, error, width, enabled, canSelect, onChoose, onEdit, onRemove }: {
26
+ identity: string;
27
+ prompts: readonly SavedPrompt[];
28
+ error?: string;
29
+ width: number;
30
+ enabled: boolean;
31
+ canSelect(): boolean;
32
+ onChoose(text: string): void;
33
+ onEdit(prompt: SavedPrompt): void;
34
+ onRemove(id: string): void;
35
+ }): import("react").JSX.Element;
19
36
  /** Confirmation for workspace removal or session archival.
20
37
  * @param props - Reviewed target and its confirmation actions.
21
38
  * @returns The confirmation dialog.
@@ -27,7 +44,7 @@ export declare function RemovalDialog({ removal, enabled, canSelect, onCancel, o
27
44
  onCancel(): void;
28
45
  onConfirm(): void;
29
46
  }): import("react").JSX.Element;
30
- import type { ModelState } from '../../session/index.ts';
47
+ import type { ModelState } from '../../contracts.ts';
31
48
  /** Two-step model and reasoning-effort selector; the state shape is shared with the session. */
32
49
  export type { ModelState };
33
50
  /** Model routes and adapter-owned reasoning efforts.
@@ -1,9 +1,10 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  /** Modal panels and list screens rendered inside the shared composer frame. */
3
3
  import { Box, Text } from 'ink';
4
- import { array, object, safeText, string } from "../../transport/wire.js";
5
- import { toolLine } from "../../session/transcript.js";
6
- import { COMMAND_HINTS, COMMAND_LABELS, COMMAND_LABEL_WIDTH } from "../commands/registry.js";
4
+ import { array, object, string } from "../../json.js";
5
+ import { safeText } from "../../text.js";
6
+ import { toolLine } from "../../text.js";
7
+ import { COMMAND_HINTS, COMMAND_LABELS, COMMAND_LABEL_WIDTH } from "../../slash/registry.js";
7
8
  import { useTheme } from "../theme/index.js";
8
9
  import { Picker } from "./picker.js";
9
10
  /** Pending host input with explicit removal.
@@ -17,6 +18,21 @@ export function QueueDialog({ queued, rows, width, unavailable, enabled, canSele
17
18
  remove: () => onRemove(item.id),
18
19
  })), pageSize: Math.max(1, Math.min(6, rows - 12)), hint: "\u2191 \u2193 select \u00B7 Enter / d / Delete remove \u00B7 Esc close", enabled: enabled, canSelect: canSelect })] });
19
20
  }
21
+ /** Operator-saved shortcut prompts with choose, edit and delete.
22
+ *
23
+ * Enter hands the text to the composer rather than sending it, so the reader can adjust it first.
24
+ * @param props - Saved entries, their load error, geometry and the three row actions.
25
+ * @returns The saved-prompt picker.
26
+ */
27
+ export function PromptsDialog({ identity, prompts, error, width, enabled, canSelect, onChoose, onEdit, onRemove }) {
28
+ const theme = useTheme();
29
+ return _jsxs(Box, { flexDirection: "column", marginY: 1, children: [_jsx(Text, { bold: true, children: "Saved prompts \u00B7 Esc close" }), error !== undefined && _jsx(Text, { color: theme.colors.error, wrap: "truncate-end", children: safeText(error) }), !prompts.length && _jsx(Text, { dimColor: true, children: "No saved prompts \u00B7 add one with /prompt TEXT" }), _jsx(Picker, { choices: prompts.map(prompt => ({
30
+ key: prompt.id, label: toolLine(prompt.text, width - 6),
31
+ action: () => onChoose(prompt.text),
32
+ edit: () => onEdit(prompt),
33
+ remove: () => onRemove(prompt.id),
34
+ })), hint: "\u2191 \u2193 select \u00B7 Enter use \u00B7 e edit \u00B7 d/Delete delete \u00B7 Esc close", enabled: enabled, canSelect: canSelect }, identity)] });
35
+ }
20
36
  /** Confirmation for workspace removal or session archival.
21
37
  * @param props - Reviewed target and its confirmation actions.
22
38
  * @returns The confirmation dialog.
@@ -0,0 +1,43 @@
1
+ import type { LoopLimits, LoopRecord } from '../../contracts.ts';
2
+ /** The numeric fields the form edits, in the order a run reads them. */
3
+ export type LoopField = 'from' | 'to' | 'score' | 'tries';
4
+ /** What the form hands to the runner: the four numbers and the record's variables as confirmed. */
5
+ export interface LoopRun {
6
+ limits: LoopLimits;
7
+ vars: Readonly<Record<string, string>>;
8
+ }
9
+ /** The values a run from one record would start with, which the form shows before anything is typed.
10
+ * @param record - Chosen record.
11
+ * @returns Its first round, last round and declared score and attempt budgets.
12
+ */
13
+ export declare function loopRecordDefaults(record: LoopRecord): LoopLimits;
14
+ /** The record list shown under the composer while `/loop` is being typed.
15
+ *
16
+ * It offers the same names and defaults the runner reads, so choosing from it cannot start a run
17
+ * other than the one the row describes.
18
+ * @param props - Matching records and the highlighted row.
19
+ * @returns The key line and up to six record rows.
20
+ */
21
+ export declare function LoopMenu({ records, index }: {
22
+ records: readonly LoopRecord[];
23
+ index: number;
24
+ }): import("react").JSX.Element;
25
+ /** The loop parameter form: one record's variables and defaults, editable in place, then a Start.
26
+ *
27
+ * Values are replaced by typing over them, so no flag has to be remembered and every default stays
28
+ * visible until it is changed. A record variable is free text because it is a path or a target, not a
29
+ * number; the four limits share the command line's own validation. Leaving a row — with Enter or with
30
+ * an arrow — commits what was typed, so no box needs its own confirmation and Start reads the values
31
+ * as displayed. The run starts only from the Start row, so no keystroke that edits a value can launch
32
+ * it by accident. The form stays readable and escapable even when the host is busy or gone; only Start
33
+ * is unavailable then.
34
+ * @param props - The chosen record, whether a run may start now, and the three row actions.
35
+ * @returns The form.
36
+ */
37
+ export declare function LoopDialog({ record, enabled, onStart, onBack, onClose }: {
38
+ record: LoopRecord;
39
+ enabled: boolean;
40
+ onStart(run: LoopRun): void;
41
+ onBack(): void;
42
+ onClose(): void;
43
+ }): import("react").JSX.Element;
@@ -0,0 +1,224 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /** The `/loop` record list and the parameter form that confirms a chosen record's defaults. */
3
+ import { useMemo, useState } from 'react';
4
+ import { Box, Text, useInput } from 'ink';
5
+ import { validLoopOption } from "../../slash/parse.js";
6
+ import { safeText } from "../../text.js";
7
+ import { useCopyMode } from "../copy-mode.js";
8
+ import { useTheme } from "../theme/index.js";
9
+ const FIELDS = [
10
+ { field: 'from', label: 'From', hint: 'first round, 1 or more' },
11
+ { field: 'to', label: 'To', hint: 'last round, at least From' },
12
+ { field: 'score', label: 'Pass', hint: 'passing score, 0–10' },
13
+ { field: 'tries', label: 'Tries', hint: 'attempts per round, 1 or more' },
14
+ ];
15
+ /** The values a run from one record would start with, which the form shows before anything is typed.
16
+ * @param record - Chosen record.
17
+ * @returns Its first round, last round and declared score and attempt budgets.
18
+ */
19
+ export function loopRecordDefaults(record) {
20
+ return { from: 1, to: record.steps, score: record.defaultScore, tries: record.defaultTries };
21
+ }
22
+ /** The form's rows for one record: its own variables first, then the shared run limits.
23
+ *
24
+ * The record's variables lead because they are why this record's form was opened at all: the document
25
+ * under review is the decision that matters most, and it is not something the command line spells out.
26
+ * @param record - Chosen record.
27
+ * @returns The ordered rows.
28
+ */
29
+ function rowsFor(record) {
30
+ return [
31
+ { kind: 'start' },
32
+ ...Object.keys(record.vars).map(name => ({ kind: 'var', name })),
33
+ ...FIELDS.map(spec => ({ kind: 'field', spec })),
34
+ { kind: 'back' },
35
+ ];
36
+ }
37
+ /** Columns the label occupies, so the values line up whatever names a record declares.
38
+ * @param rows - Rows being rendered.
39
+ * @returns One past the longest label.
40
+ */
41
+ function labelWidth(rows) {
42
+ const longest = Math.max(0, ...rows.map(row => row.kind === 'field' ? row.spec.label.length
43
+ : row.kind === 'var' ? row.name.length : 0));
44
+ return Math.max(5, longest) + 1;
45
+ }
46
+ /** The record list shown under the composer while `/loop` is being typed.
47
+ *
48
+ * It offers the same names and defaults the runner reads, so choosing from it cannot start a run
49
+ * other than the one the row describes.
50
+ * @param props - Matching records and the highlighted row.
51
+ * @returns The key line and up to six record rows.
52
+ */
53
+ export function LoopMenu({ records, index }) {
54
+ const theme = useTheme();
55
+ const start = Math.max(0, index - 5);
56
+ return _jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "Loop records \u00B7 \u2191 \u2193 select \u00B7 Enter confirm defaults \u00B7 Tab finish the name \u00B7 Esc close" }), records.slice(start, start + 6).map((record, offset) => {
57
+ const current = start + offset === index;
58
+ // The record's own inputs come before the artifact file: they are what the form will edit, and
59
+ // the first thing a narrow row must not lose.
60
+ const vars = Object.entries(record.vars).map(([name, value]) => `${name} ${value}`).join(' · ');
61
+ const target = vars === '' ? '' : ` · ${vars}`;
62
+ const artifact = record.artifact === undefined ? '' : ` · ${record.artifact}`;
63
+ return _jsxs(Text, { color: current ? theme.accent : undefined, wrap: "truncate-end", children: [current ? '❯ ' : ' ', record.name, " \u00B7 ", record.title, " \u00B7 ", record.steps, " rounds \u00B7 pass ", record.defaultScore, " \u00B7 \u2264", record.defaultTries, " tries", target, artifact] }, record.name);
64
+ })] });
65
+ }
66
+ /** The loop parameter form: one record's variables and defaults, editable in place, then a Start.
67
+ *
68
+ * Values are replaced by typing over them, so no flag has to be remembered and every default stays
69
+ * visible until it is changed. A record variable is free text because it is a path or a target, not a
70
+ * number; the four limits share the command line's own validation. Leaving a row — with Enter or with
71
+ * an arrow — commits what was typed, so no box needs its own confirmation and Start reads the values
72
+ * as displayed. The run starts only from the Start row, so no keystroke that edits a value can launch
73
+ * it by accident. The form stays readable and escapable even when the host is busy or gone; only Start
74
+ * is unavailable then.
75
+ * @param props - The chosen record, whether a run may start now, and the three row actions.
76
+ * @returns The form.
77
+ */
78
+ export function LoopDialog({ record, enabled, onStart, onBack, onClose }) {
79
+ const theme = useTheme();
80
+ const copyMode = useCopyMode();
81
+ const defaults = loopRecordDefaults(record);
82
+ const rows = useMemo(() => rowsFor(record), [record]);
83
+ const width = useMemo(() => labelWidth(rows), [rows]);
84
+ const [row, setRow] = useState(0);
85
+ const [values, setValues] = useState(defaults);
86
+ const [vars, setVars] = useState({ ...record.vars });
87
+ const [edit, setEdit] = useState();
88
+ const [error, setError] = useState();
89
+ const current = rows[Math.min(row, rows.length - 1)];
90
+ /** Validate and apply the selected editable row's buffer.
91
+ *
92
+ * Leaving a row takes its value with it, so no box needs its own Enter: an arrow to the next row
93
+ * commits what was typed, and Start then reads the committed values. A value that cannot be used
94
+ * keeps the cursor on its row and says why, so nothing is dropped silently.
95
+ * @returns True when it is safe to leave the row.
96
+ */
97
+ const applyEdit = () => {
98
+ if (edit === undefined || current.kind === 'start' || current.kind === 'back')
99
+ return true;
100
+ if (current.kind === 'var') {
101
+ const value = edit.trim();
102
+ if (value === '') {
103
+ setError(`${current.name}: a value is required`);
104
+ return false;
105
+ }
106
+ if (value !== vars[current.name])
107
+ setVars({ ...vars, [current.name]: value });
108
+ setEdit(undefined);
109
+ setError(undefined);
110
+ return true;
111
+ }
112
+ // Nothing typed (or the value erased) leaves the field as it was rather than reading `''` as 0.
113
+ if (edit === '') {
114
+ setEdit(undefined);
115
+ setError(undefined);
116
+ return true;
117
+ }
118
+ const parsed = Number(edit);
119
+ if (!validLoopOption(current.spec.field, parsed)) {
120
+ setError(`${current.spec.label}: ${current.spec.hint}`);
121
+ return false;
122
+ }
123
+ const next = { ...values, [current.spec.field]: parsed };
124
+ if (next.to < next.from) {
125
+ setError('To must be at least From');
126
+ return false;
127
+ }
128
+ setValues(next);
129
+ setEdit(undefined);
130
+ setError(undefined);
131
+ return true;
132
+ };
133
+ const move = (next) => {
134
+ if (!applyEdit())
135
+ return;
136
+ setRow(Math.max(0, Math.min(rows.length - 1, next)));
137
+ setEdit(undefined);
138
+ setError(undefined);
139
+ };
140
+ useInput((input, key) => {
141
+ if (key.eventType === 'release')
142
+ return;
143
+ if (key.escape) {
144
+ if (edit !== undefined) {
145
+ setEdit(undefined);
146
+ setError(undefined);
147
+ }
148
+ else
149
+ onClose();
150
+ return;
151
+ }
152
+ if (key.upArrow) {
153
+ move(row - 1);
154
+ return;
155
+ }
156
+ if (key.downArrow) {
157
+ move(row + 1);
158
+ return;
159
+ }
160
+ if (current.kind === 'var') {
161
+ const { name } = current;
162
+ if (key.backspace || key.delete) {
163
+ setEdit((edit ?? vars[name] ?? '').slice(0, -1));
164
+ setError(undefined);
165
+ return;
166
+ }
167
+ // A variable is a path or a target, so anything printable goes in; control bytes never do.
168
+ const typed = input.replace(/[\u0000-\u001f\u007f]/g, '');
169
+ if (typed !== '' && !key.ctrl && !key.meta) {
170
+ setEdit(edit === undefined ? typed : edit + typed);
171
+ setError(undefined);
172
+ return;
173
+ }
174
+ if (key.return)
175
+ move(row + 1);
176
+ return;
177
+ }
178
+ if (current.kind === 'field') {
179
+ const { field } = current.spec;
180
+ if (key.backspace || key.delete) {
181
+ setEdit((edit ?? String(values[field])).slice(0, -1));
182
+ setError(undefined);
183
+ return;
184
+ }
185
+ if (/^[0-9.]$/.test(input)) {
186
+ setEdit(edit === undefined ? input : edit + input);
187
+ setError(undefined);
188
+ return;
189
+ }
190
+ if (key.return)
191
+ move(row + 1);
192
+ return;
193
+ }
194
+ if (!key.return)
195
+ return;
196
+ if (current.kind === 'back') {
197
+ onBack();
198
+ return;
199
+ }
200
+ if (!enabled) {
201
+ setError('Cannot start: the host is busy or offline');
202
+ return;
203
+ }
204
+ if (values.to < values.from) {
205
+ setError('To must be at least From');
206
+ return;
207
+ }
208
+ onStart({ limits: values, vars });
209
+ }, { isActive: !copyMode });
210
+ return _jsxs(Box, { flexDirection: "column", marginY: 1, children: [_jsxs(Text, { bold: true, children: ["Run loop record \u00B7 ", safeText(record.name), " \u00B7 Esc close"] }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [record.steps, " rounds", record.artifact === undefined ? '' : ` · ${safeText(record.artifact)}`, " \u00B7 \u2191 \u2193 to a row, then type"] }), rows.map((item, index) => {
211
+ const selected = index === row;
212
+ const cursor = selected ? '❯ ' : ' ';
213
+ if (item.kind === 'start' || item.kind === 'back')
214
+ return _jsxs(Text, { color: selected ? theme.accent : undefined, children: [cursor, item.kind === 'start' ? 'Start run' : '← Choose another record'] }, item.kind);
215
+ if (item.kind === 'var') {
216
+ const value = vars[item.name] ?? '';
217
+ const shown = selected && edit !== undefined ? edit : value;
218
+ return _jsxs(Text, { color: selected ? theme.accent : undefined, children: [cursor, item.name.padEnd(width), selected && edit !== undefined ? _jsx(Text, { inverse: true, children: shown || ' ' }) : shown, value === record.vars[item.name] ? null : _jsxs(Text, { dimColor: true, children: [" \u00B7 default ", record.vars[item.name]] })] }, `var:${item.name}`);
219
+ }
220
+ const { field, label } = item.spec;
221
+ const shown = selected && edit !== undefined ? edit : String(values[field]);
222
+ return _jsxs(Text, { color: selected ? theme.accent : undefined, children: [cursor, label.padEnd(width), selected && edit !== undefined ? _jsx(Text, { inverse: true, children: shown || ' ' }) : shown, values[field] === defaults[field] ? null : _jsxs(Text, { dimColor: true, children: [" \u00B7 default ", defaults[field]] })] }, `field:${field}`);
223
+ }), error !== undefined && _jsx(Text, { color: theme.colors.error, children: safeText(error) }), _jsx(Text, { dimColor: true, children: "Enter edit a value / start \u00B7 type to replace \u00B7 Esc close" })] });
224
+ }