@buildaharness/aielia 0.3.5 → 0.3.7

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 (47) hide show
  1. package/README.md +97 -19
  2. package/dist/action-approval-service.d.ts +34 -2
  3. package/dist/action-record.d.ts +13 -0
  4. package/dist/activity-log.d.ts +4 -0
  5. package/dist/agent-loop.d.ts +83 -1
  6. package/dist/assistant-session.d.ts +20 -1
  7. package/dist/assistant-types.d.ts +3 -1
  8. package/dist/assistant.d.ts +32 -0
  9. package/dist/{bin-CjKpxDxl.js → bin-C_hgDdSf.js} +369 -219
  10. package/dist/bin-C_hgDdSf.js.map +1 -0
  11. package/dist/cli-config.d.ts +3 -0
  12. package/dist/cli-session.d.ts +5 -4
  13. package/dist/cli.d.ts +9 -0
  14. package/dist/cli.js +1 -1
  15. package/dist/config.d.ts +3 -1
  16. package/dist/debug-log.d.ts +10 -1
  17. package/dist/email.d.ts +5 -3
  18. package/dist/file-tools-mcp-server.mjs +155 -30
  19. package/dist/file-tools.d.ts +2 -0
  20. package/dist/git-exclude.d.ts +9 -0
  21. package/dist/index.d.ts +1 -1
  22. package/dist/index.js +127 -126
  23. package/dist/ink-select-prompt.d.ts +2 -0
  24. package/dist/ink-test-render.d.ts +2 -0
  25. package/dist/memory-service.d.ts +8 -0
  26. package/dist/next-step-proposer.d.ts +7 -0
  27. package/dist/plan-drafting-service.d.ts +3 -0
  28. package/dist/plan-store.d.ts +5 -0
  29. package/dist/{provider-setup-LFsaTkpf.js → provider-setup-wX2IBw2X.js} +2183 -919
  30. package/dist/provider-setup-wX2IBw2X.js.map +1 -0
  31. package/dist/reply-audit.d.ts +67 -0
  32. package/dist/side-call-policy.d.ts +30 -0
  33. package/dist/tool-step.d.ts +6 -0
  34. package/dist/trace-events.d.ts +1 -1
  35. package/dist/trust-tagging.d.ts +2 -0
  36. package/dist/{tui-app-BLR-PRFp.js → tui-app-C0J6HRzv.js} +127 -26
  37. package/dist/tui-app-C0J6HRzv.js.map +1 -0
  38. package/dist/tui-app.d.ts +35 -0
  39. package/dist/tui-mode-flag.d.ts +1 -1
  40. package/dist/turn-abort.d.ts +18 -0
  41. package/dist/turn-intent-classifier.d.ts +8 -0
  42. package/dist/turn-interpreter.d.ts +2 -0
  43. package/dist/version.d.ts +1 -1
  44. package/package.json +3 -3
  45. package/dist/bin-CjKpxDxl.js.map +0 -1
  46. package/dist/provider-setup-LFsaTkpf.js.map +0 -1
  47. package/dist/tui-app-BLR-PRFp.js.map +0 -1
@@ -0,0 +1,67 @@
1
+ import { ILLMClient, TokenUsage } from '@buildaharness/runtime';
2
+ /**
3
+ * A reply can say things the turn did not do (benchmark scenarios 13, 15 and 10): "Done, I added the type hints"
4
+ * with no write behind it, "let me fix all of it" followed by nothing, or facts about a changelog the assistant
5
+ * never read because the network was unavailable. The reply is already on screen when this runs (it is streamed),
6
+ * so the result is a separate notice, like `reviewNotice`.
7
+ */
8
+ export interface ReplyAuditInput {
9
+ userMessage: string;
10
+ reply: string;
11
+ /** What the system recorded as carried out this turn (write/shell/email), see describeAppliedAction. */
12
+ actions: string[];
13
+ /** Parallel to `actions`: for a write, what it changed (a short diff), so a reply's account of the file can be checked against it. */
14
+ actionDetails?: string[];
15
+ /** What it recorded in earlier turns of this session (most recent last), so restating old results is not mistaken for new work. */
16
+ earlierActions?: string[];
17
+ /** Recent shell commands with the start and end of their output (most recent last). */
18
+ recentCommandOutputs?: {
19
+ command: string;
20
+ output: string;
21
+ }[];
22
+ /** Files and pages the turn read (tool and target). */
23
+ sourcesRead: string[];
24
+ /** Files, directories and pages read in EARLIER turns of this session (most recent last): an answer about them is not an outside fact, and a statement about which tools were used must be checked against them too. */
25
+ earlierSourcesRead?: string[];
26
+ /** A command this session already got the network-containment refusal for. */
27
+ lookupUnavailable: boolean;
28
+ }
29
+ export interface ReplyAudit {
30
+ claimsUnrecordedWork: boolean;
31
+ promisesWorkNotDone: boolean;
32
+ unverifiedOutsideFacts: boolean;
33
+ contradictsCommandOutput: boolean;
34
+ /** The reply denies or contradicts what the system recorded: says it did not touch, run or use something that a recorded write, command or read shows it did (benchmark scenarios 12 and 08). */
35
+ contradictsRecordedWork: boolean;
36
+ /** The reply thinks aloud: it starts a statement, then corrects or retracts it inside the answer, instead of one clean answer (benchmark scenarios 08 and 14). */
37
+ leaksSelfCorrection: boolean;
38
+ /** The reply does not respond to the user's message at all: placeholder text, a stray JSON object, an unrelated topic (benchmark scenario 13 turn 3: the whole reply was `{"favorite_marble":"... the response ..."}`). */
39
+ nonAnswer: boolean;
40
+ }
41
+ export declare const CLEAN_AUDIT: Readonly<ReplyAudit>;
42
+ /** Replies longer than this are never checked for being a non-answer: junk such as a stray JSON object or placeholder text is short, and the extra calls are not worth making for a long reply. */
43
+ export declare const NON_ANSWER_CHECK_MAX_CHARS = 300;
44
+ /**
45
+ * Two audit calls at once, and a third only when they disagree about a category: a category stands when at least two of the calls
46
+ * raise it. One sample of a classifier is noisy both ways: a false flag costs the user a pointless correction turn, a miss lets an
47
+ * invented claim stand (benchmark scenario 10 turn 2: the same reply was flagged by 6 of 8 production audits and passed by the
48
+ * rest). Asking the second call only after a flag made a miss on the first call final; asking both together also takes less time
49
+ * when the endpoint is slow. Fewer than two usable answers (an error or unparseable reply) returns a clean audit: a failed check
50
+ * must never flag a reply.
51
+ */
52
+ export declare function auditReply(input: ReplyAuditInput, llmClient: ILLMClient, model?: string, onUsage?: (usage: TokenUsage) => void, options?: {
53
+ checkNonAnswer?: boolean;
54
+ }): Promise<ReplyAudit>;
55
+ /** The user-facing lines for what the audit found; undefined when it found nothing. */
56
+ export declare function replyAuditNotice(audit: ReplyAudit, actions: string[]): string | undefined;
57
+ /**
58
+ * `AIELIA_REPLY_AUDIT` gate. Default ON outside vitest (one extra bounded LLM call per successful turn); `0`/`false`/`off`/`no`/`disabled`
59
+ * turns it off. Same shape as `semanticGroundingEnabled()`.
60
+ */
61
+ export declare function replyAuditEnabled(env?: Record<string, string | undefined>): boolean;
62
+ /**
63
+ * The message for the one automatic retry, or undefined when the reply needs none. A claim or promise of work the system
64
+ * did not record asks for the work to be done now or the claim withdrawn; details that disagree with the command output
65
+ * or outside facts stated as verified ask for a corrected answer.
66
+ */
67
+ export declare function auditRetryNudge(audit: ReplyAudit, recorded: string[], userMessage?: string): string | undefined;
@@ -0,0 +1,30 @@
1
+ import { ILLMClient } from '@buildaharness/runtime';
2
+ /**
3
+ * The small structured JSON calls a turn makes around its reply (risk classification, scope and goal-thread matching, the reply
4
+ * audit and its extra samples, next steps) are quick on a healthy endpoint, but a few simply never answer, and a turn waits on all of
5
+ * them: in the benchmark a finished-looking reply stayed busy for minutes and a queued message started late (scenarios 05, 06, 09, 10,
6
+ * 13, 15). Measured on the benchmark's model through OpenRouter with the real risk-classifier prompt (16 calls, 4 at a time): median
7
+ * 12 s, 75th percentile 27 s, 90th percentile 59 s, and one call that had not answered after 300 s. So a legitimate answer can take
8
+ * about a minute, and a fixed short bound kills those (a 45 s bound did: two turns of benchmark scenario 03 ended as "risk
9
+ * classification failed" and were never answered), while a long bound makes every hang cost minutes.
10
+ *
11
+ * So the call is hedged instead of cut off: when the first attempt has not answered after HEDGE_AFTER_MS a second identical attempt
12
+ * starts beside it, and whichever answers first is used. A slow answer is never discarded, a hung call costs about the hedge delay
13
+ * plus a normal response, and each attempt keeps its own hard bound. A failure waits for the other attempt if one is running; after that a
14
+ * transient one (a timeout, 408, 429, 5xx: benchmark scenarios 02, 08 and 10 each ended a classification on "The LLM proxy returned an error")
15
+ * gets one more attempt, while a refusal or a malformed answer would come back the same and is returned at once.
16
+ */
17
+ export declare const SIDE_CALL_HEDGE_AFTER_MS = 25000;
18
+ export declare const SIDE_CALL_TIMEOUT_MS = 120000;
19
+ /** A failure worth one more attempt: a request that timed out, or an endpoint status that comes and goes (408, 429, any 5xx). A 4xx refusal or a bad answer is final. */
20
+ export declare function isTransientFailure(err: unknown): boolean;
21
+ export interface SideCallPolicyOptions {
22
+ /** Called once when a second attempt is started because the first has not answered yet. */
23
+ onHedge?: (info: {
24
+ afterMs: number;
25
+ }) => void;
26
+ /** Test hook: how long the first attempt gets before the second starts. */
27
+ hedgeAfterMs?: number;
28
+ }
29
+ /** Wraps a client so every structuredOutput call (never a main chat or tool-loop call) is hedged as described above. */
30
+ export declare function withSideCallPolicy(client: ILLMClient, policy?: SideCallPolicyOptions): ILLMClient;
@@ -31,3 +31,9 @@ export declare function stripMcpToolPrefix(name: string): string;
31
31
  * parsing) so "what step is this" reads identically no matter where it's rendered.
32
32
  */
33
33
  export declare function summarizeToolStep(tool: string, input: Record<string, unknown>): string;
34
+ /**
35
+ * The same step once its turn has finished: a write or command that was only "proposed" while the model was working is
36
+ * reported as done ("Wrote …", "Ran: …"), so the transcript never says "Proposing" about something that already happened.
37
+ * Only call this for a step of a completed turn that was not denied (`deniedReason` unset); every other tool reads the same.
38
+ */
39
+ export declare function summarizeSettledToolStep(tool: string, input: Record<string, unknown>): string;
@@ -14,7 +14,7 @@ export type TraceEvent = {
14
14
  } | {
15
15
  kind: 'turn_end';
16
16
  sessionId: string;
17
- status: 'ok' | 'needs_approval' | 'escalated' | 'needs_clarification' | 'needs_plan_approval';
17
+ status: 'ok' | 'needs_approval' | 'escalated' | 'needs_clarification' | 'needs_plan_approval' | 'cancelled';
18
18
  } | {
19
19
  kind: 'risk_classified';
20
20
  riskLevel: RiskLevel;
@@ -7,6 +7,8 @@ import { ILLMClient, TokenUsage } from '@buildaharness/runtime';
7
7
  * instruction that content in this tag is data, never instructions.
8
8
  */
9
9
  export declare function wrapUntrusted(text: string): string;
10
+ /** A literal delimiter tag inside text is neutralised (`&lt;`) so the text cannot close or re-open the untrusted boundary. String scan, not a regex: a structural delimiter check, not a natural-language gate. */
11
+ export declare function defangUntrustedTags(text: string): string;
10
12
  export interface InjectionDetection {
11
13
  flagged: boolean;
12
14
  reason?: string;
@@ -3,11 +3,11 @@ var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { en
3
3
  var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
4
4
  import { jsxs, jsx } from "react/jsx-runtime";
5
5
  import { Readable, Writable } from "node:stream";
6
- import { useState, useRef, useMemo, useCallback, useSyncExternalStore, useEffect } from "react";
6
+ import { useState, useRef, useEffect, useMemo, useCallback, useSyncExternalStore } from "react";
7
7
  import { useWindowSize, usePaste, useInput, Box, Text, render, Static } from "ink";
8
- import { r as renderPlan, t as toLines, a as runCli, P as PLAN_LINE_PREFIX, I as ICONS } from "./bin-CjKpxDxl.js";
8
+ import { s as sanitizeLabel, r as renderPlan, t as toLines, a as runCli, P as PLAN_LINE_PREFIX, I as ICONS } from "./bin-C_hgDdSf.js";
9
9
  import { format } from "node:util";
10
- import { n as DIFF_INDENT } from "./provider-setup-LFsaTkpf.js";
10
+ import { o as DIFF_INDENT, bM as stripTerminalControls } from "./provider-setup-wX2IBw2X.js";
11
11
  function classify(raw, stream) {
12
12
  if (raw.startsWith("\r")) {
13
13
  return { type: "progress", text: raw.replace(/\r/g, "").trimEnd() };
@@ -99,6 +99,15 @@ function TuiInput(props) {
99
99
  const [history, setHistory] = useState([]);
100
100
  const [historyIndex, setHistoryIndex] = useState(null);
101
101
  const [pendingDraft, setPendingDraft] = useState("");
102
+ const previousPromptLabel = useRef(promptLabel);
103
+ useEffect(() => {
104
+ if (previousPromptLabel.current === promptLabel) return;
105
+ previousPromptLabel.current = promptLabel;
106
+ setValue("");
107
+ setCursor(0);
108
+ setHistoryIndex(null);
109
+ setPendingDraft("");
110
+ }, [promptLabel]);
102
111
  const windowSize = useWindowSize();
103
112
  const terminalColumns = props.columns ?? windowSize.columns;
104
113
  const wrapWidth = Math.max(1, terminalColumns - BOX_CHROME_WIDTH);
@@ -212,10 +221,20 @@ function TuiInput(props) {
212
221
  ] });
213
222
  }
214
223
  function SelectPrompt(props) {
215
- const { question, options, onSubmit, isActive = true } = props;
224
+ const { question, options, onSubmit, isActive = true, armDelayMs = 0 } = props;
216
225
  const [highlighted, setHighlighted] = useState(0);
226
+ const armed = useRef(armDelayMs <= 0);
227
+ useEffect(() => {
228
+ if (armDelayMs <= 0) return;
229
+ armed.current = false;
230
+ const timer = setTimeout(() => {
231
+ armed.current = true;
232
+ }, armDelayMs);
233
+ return () => clearTimeout(timer);
234
+ }, [armDelayMs, question, options]);
217
235
  useInput(
218
236
  (input, key) => {
237
+ if (!armed.current) return;
219
238
  if (key.upArrow) {
220
239
  setHighlighted((h) => (h - 1 + options.length) % options.length);
221
240
  return;
@@ -272,11 +291,17 @@ const planNavigator = {
272
291
  return { cur: m.taskOrder[(i2 + (key === "tab" ? 1 : -1) + n) % n], k: 0 };
273
292
  }
274
293
  if (key === "up" || key === "down") {
275
- const origin = st.lastKey === key && st.lastFrom !== void 0 ? st.lastFrom : st.cur;
276
- const cands = byX(m, (key === "up" ? m.deps[origin] : m.dependents[origin]) ?? []);
294
+ let origin = st.lastKey === key && st.lastFrom !== void 0 ? st.lastFrom : st.cur;
295
+ let cands = byX(m, (key === "up" ? m.deps[origin] : m.dependents[origin]) ?? []);
296
+ let repeating = st.lastKey === key;
297
+ if (repeating && cands.length < 2) {
298
+ origin = st.cur;
299
+ cands = byX(m, (key === "up" ? m.deps[origin] : m.dependents[origin]) ?? []);
300
+ repeating = false;
301
+ }
277
302
  if (!cands.length) return st;
278
303
  const nearest = [...cands].sort((a, b) => Math.abs(m.x[a] - m.x[origin]) - Math.abs(m.x[b] - m.x[origin]) || cmp(a, b))[0];
279
- const k = st.lastKey === key ? (st.k + 1) % cands.length : 0;
304
+ const k = repeating ? (st.k + 1) % cands.length : 0;
280
305
  return { cur: cands[(cands.indexOf(nearest) + k) % cands.length], lastKey: key, lastFrom: origin, k };
281
306
  }
282
307
  const rank = byX(m, m.ids.filter((id) => m.y[id] === m.y[st.cur]));
@@ -338,17 +363,17 @@ function planDetailLines(nodes, id, width) {
338
363
  const w = Math.max(10, width);
339
364
  const name = (i) => {
340
365
  var _a;
341
- return ((_a = nodes.find((n) => n.id === i)) == null ? void 0 : _a.id) ?? i;
366
+ return sanitizeLabel(((_a = nodes.find((n) => n.id === i)) == null ? void 0 : _a.id) ?? i);
342
367
  };
343
368
  const dependents = nodes.filter((n) => n.deps.includes(node.id)).map((n) => n.id);
344
369
  return [
345
- ...wrap(node.id, w),
370
+ ...wrap(sanitizeLabel(node.id), w),
346
371
  "",
347
- ...wrap(node.label, w),
372
+ ...wrap(sanitizeLabel(node.label), w),
348
373
  "",
349
374
  `Status: ${STATUS_WORD[node.status] ?? node.status}`,
350
375
  ...wrap(`Depends on: ${node.deps.length ? node.deps.map(name).join(", ") : "nothing"}`, w),
351
- ...wrap(`Needed by: ${dependents.length ? dependents.join(", ") : "nothing"}`, w)
376
+ ...wrap(`Needed by: ${dependents.length ? dependents.map(sanitizeLabel).join(", ") : "nothing"}`, w)
352
377
  ];
353
378
  }
354
379
  const DRAWER_DOCK_COLUMNS = 120;
@@ -506,7 +531,7 @@ const ASSISTANT_INDENT_WIDTH = 2;
506
531
  function useStore(store) {
507
532
  return useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
508
533
  }
509
- const EMPTY_LOG = { lines: [], progressText: "", transientText: "", waitingForOutput: false };
534
+ const EMPTY_LOG = { lines: [], progressText: "", transientText: "", waitingForOutput: false, turnInFlight: false };
510
535
  const TOOL_STEP_PREFIXES = [ICONS.toolStep, ICONS.proposalStep, ICONS.deniedStep];
511
536
  const ASSISTANT_REPLY_PREFIX = "Aielia>";
512
537
  function classifyLineKind(mergedText, streamedReplyWasOpen, stream) {
@@ -528,6 +553,8 @@ class EventLogBridge {
528
553
  __publicField(this, "progressText", "");
529
554
  __publicField(this, "transientText", "");
530
555
  __publicField(this, "waitingForOutput", false);
556
+ __publicField(this, "turnInFlight", false);
557
+ __publicField(this, "lastActivityAt");
531
558
  __publicField(this, "snapshot", EMPTY_LOG);
532
559
  __publicField(this, "listeners", /* @__PURE__ */ new Set());
533
560
  __publicField(this, "subscribe", (listener) => {
@@ -539,12 +566,41 @@ class EventLogBridge {
539
566
  __publicField(this, "getSnapshot", () => this.snapshot);
540
567
  }
541
568
  commit() {
542
- this.snapshot = { lines: this.lines, progressText: this.progressText, transientText: this.transientText, waitingForOutput: this.waitingForOutput };
569
+ this.snapshot = {
570
+ lines: this.lines,
571
+ progressText: this.progressText,
572
+ transientText: this.transientText,
573
+ waitingForOutput: this.waitingForOutput,
574
+ turnInFlight: this.turnInFlight,
575
+ lastActivityAt: this.lastActivityAt
576
+ };
543
577
  for (const listener of this.listeners) listener();
544
578
  }
545
579
  /** Marks the start of a turn's wait for its first output (see `TuiLogState.waitingForOutput`'s doc comment) — called by `TuiApp` right after a chat line or resolved prompt answer is submitted. */
546
580
  beginTurn() {
547
581
  this.waitingForOutput = true;
582
+ this.turnInFlight = true;
583
+ this.lastActivityAt = Date.now();
584
+ this.commit();
585
+ }
586
+ /** A resolved approval/clarification answer: output is expected again, but it says nothing about whether a turn is in flight — `TurnActivityTracker` owns that, so a prompt answer can never leave the indicator stuck on. */
587
+ expectOutput() {
588
+ this.waitingForOutput = true;
589
+ this.commit();
590
+ }
591
+ /** Marks a turn in flight (idempotent); the elapsed counter counts from the first call. */
592
+ markBusy() {
593
+ if (this.turnInFlight) return;
594
+ this.turnInFlight = true;
595
+ this.lastActivityAt = Date.now();
596
+ this.commit();
597
+ }
598
+ /** Marks the turn finished (its `dispatchLine()` settled) — the liveness line stops. Safe to call when no turn is in flight. */
599
+ endTurn() {
600
+ if (!this.turnInFlight && !this.waitingForOutput) return;
601
+ this.turnInFlight = false;
602
+ this.waitingForOutput = false;
603
+ this.lastActivityAt = void 0;
548
604
  this.commit();
549
605
  }
550
606
  /**
@@ -570,18 +626,19 @@ class EventLogBridge {
570
626
  }
571
627
  handleEvent(event) {
572
628
  this.waitingForOutput = false;
629
+ if (this.turnInFlight) this.lastActivityAt = Date.now();
573
630
  if (event.type === "progress") {
574
- this.progressText = event.text;
631
+ this.progressText = stripTerminalControls(event.text);
575
632
  this.commit();
576
633
  return;
577
634
  }
578
635
  if (event.type === "token") {
579
- this.transientText += event.text;
636
+ this.transientText += stripTerminalControls(event.text);
580
637
  this.commit();
581
638
  return;
582
639
  }
583
640
  const streamedReplyWasOpen = this.transientText.length > 0;
584
- const merged = this.transientText + event.lines.join("\n");
641
+ const merged = this.transientText + stripTerminalControls(event.lines.join("\n"));
585
642
  const kind = classifyLineKind(merged, streamedReplyWasOpen, event.stream);
586
643
  const displayText = kind === "assistant" ? stripAssistantLabel(merged) : kind === "plan" ? stripPlanLabel(merged) : merged;
587
644
  this.lines = kind === "plan" ? [...this.lines, { text: displayText, kind }] : (
@@ -596,6 +653,31 @@ class EventLogBridge {
596
653
  if (kind === "assistant") this.pushTurnMargin();
597
654
  }
598
655
  }
656
+ class TurnActivityTracker {
657
+ constructor(eventLog) {
658
+ __publicField(this, "dispatches", 0);
659
+ __publicField(this, "cliBusy", false);
660
+ this.eventLog = eventLog;
661
+ }
662
+ /** Wraps one `dispatchLine()` call. Always settles, whether it resolves or throws. */
663
+ async track(run) {
664
+ this.dispatches++;
665
+ try {
666
+ return await run();
667
+ } finally {
668
+ this.dispatches--;
669
+ this.settle();
670
+ }
671
+ }
672
+ setCliBusy(busy) {
673
+ this.cliBusy = busy;
674
+ if (busy) this.eventLog.markBusy();
675
+ else this.settle();
676
+ }
677
+ settle() {
678
+ if (this.dispatches === 0 && !this.cliBusy) this.eventLog.endTurn();
679
+ }
680
+ }
599
681
  class PromptBridge {
600
682
  constructor() {
601
683
  __publicField(this, "pending");
@@ -616,7 +698,10 @@ class PromptBridge {
616
698
  }
617
699
  ask(question, options) {
618
700
  return new Promise((resolve) => {
619
- this.pending = { question, options };
701
+ this.pending = {
702
+ question: stripTerminalControls(question),
703
+ options: options == null ? void 0 : options.map((o) => ({ ...o, label: stripTerminalControls(o.label) }))
704
+ };
620
705
  this.resolve = resolve;
621
706
  for (const listener of this.listeners) listener();
622
707
  });
@@ -649,6 +734,7 @@ class StatusBridge {
649
734
  const ALT_SCREEN_ON = "\x1B[?1049h";
650
735
  const ALT_SCREEN_OFF = "\x1B[?1049l";
651
736
  const ALT_SCREEN_SETTLE_MS = 80;
737
+ const SELECT_ARM_DELAY_MS = 400;
652
738
  const PLAN_GRAPH_MIN_RENDER_MS = 100;
653
739
  class PlanGraphBridge {
654
740
  constructor(writeRaw, settleMs = ALT_SCREEN_SETTLE_MS, minRenderMs = PLAN_GRAPH_MIN_RENDER_MS) {
@@ -775,15 +861,23 @@ function LogLineText({ line, width }) {
775
861
  }
776
862
  const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
777
863
  const SPINNER_INTERVAL_MS = 80;
778
- function Spinner() {
864
+ const LIVENESS_COUNTER_AFTER_S = 3;
865
+ function formatLivenessLabel(elapsedMs, hasProgress) {
866
+ const seconds = Math.max(0, Math.floor(elapsedMs / 1e3));
867
+ if (seconds < LIVENESS_COUNTER_AFTER_S) return hasProgress ? "Working…" : "Thinking…";
868
+ return `Working… ${seconds}s`;
869
+ }
870
+ function Spinner({ since, hasProgress, quietOnly = false }) {
779
871
  const [frame, setFrame] = useState(0);
780
872
  useEffect(() => {
781
873
  const id = setInterval(() => setFrame((f) => (f + 1) % SPINNER_FRAMES.length), SPINNER_INTERVAL_MS);
782
874
  return () => clearInterval(id);
783
875
  }, []);
876
+ if (quietOnly && since !== void 0 && Date.now() - since < LIVENESS_COUNTER_AFTER_S * 1e3) return null;
784
877
  return /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
785
878
  SPINNER_FRAMES[frame],
786
- " Thinking…"
879
+ " ",
880
+ formatLivenessLabel(since === void 0 ? 0 : Date.now() - since, hasProgress)
787
881
  ] });
788
882
  }
789
883
  function StatusLine({ indicators }) {
@@ -820,7 +914,7 @@ function TuiApp(props) {
820
914
  var _a, _b;
821
915
  const displayAnswer = ((_b = (_a = pending == null ? void 0 : pending.options) == null ? void 0 : _a.find((option) => option.key === line)) == null ? void 0 : _b.label) ?? line;
822
916
  eventLog.pushEchoLine("> ", (pending == null ? void 0 : pending.question) !== void 0 ? `${pending.question.trimEnd()} → ${displayAnswer}` : line);
823
- eventLog.beginTurn();
917
+ eventLog.expectOutput();
824
918
  prompt.submit(line);
825
919
  },
826
920
  [eventLog, prompt, pending]
@@ -829,7 +923,7 @@ function TuiApp(props) {
829
923
  void (planGraph == null ? void 0 : planGraph.close());
830
924
  }, [planGraph]);
831
925
  const hasTransient = log.progressText.length > 0 || log.transientText.length > 0;
832
- const showSpinner = log.waitingForOutput && !hasTransient;
926
+ const showSpinner = (log.turnInFlight || log.waitingForOutput) && !pending;
833
927
  const staticItems = graphView.mode === "chat" ? log.lines : log.lines.slice(0, frozenLines.current);
834
928
  const paneRows = Math.max(3, (windowSize.rows ?? 24) - 1);
835
929
  if (graphView.mode !== "chat") {
@@ -837,19 +931,19 @@ function TuiApp(props) {
837
931
  return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
838
932
  /* @__PURE__ */ jsx(Static, { items: staticItems, children: (line, index) => /* @__PURE__ */ jsx(LogLineText, { line, width }, index) }),
839
933
  graphView.mode === "pane" && /* @__PURE__ */ jsx(PlanGraphPane, { nodes: graphView.nodes, columns: width, rows: paneRows - promptRows, active: !pending, color: !process.env.NO_COLOR, onClose: closePane }),
840
- graphView.mode === "pane" && (pending == null ? void 0 : pending.options) && /* @__PURE__ */ jsx(SelectPrompt, { question: pending.question, options: pending.options, onSubmit: handleSubmitPrompt }),
934
+ graphView.mode === "pane" && (pending == null ? void 0 : pending.options) && /* @__PURE__ */ jsx(SelectPrompt, { question: pending.question, options: pending.options, onSubmit: handleSubmitPrompt, armDelayMs: SELECT_ARM_DELAY_MS }),
841
935
  graphView.mode === "pane" && pending && !pending.options && /* @__PURE__ */ jsx(TuiInput, { promptLabel: pending.question, onSubmitChat: handleSubmitChat, onSubmitPrompt: handleSubmitPrompt, columns })
842
936
  ] });
843
937
  }
844
938
  return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
845
939
  /* @__PURE__ */ jsx(Static, { items: staticItems, children: (line, index) => /* @__PURE__ */ jsx(LogLineText, { line, width }, index) }),
846
- showSpinner && /* @__PURE__ */ jsx(Spinner, {}),
847
940
  hasTransient && /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
848
941
  log.progressText.length > 0 && /* @__PURE__ */ jsx(Text, { dimColor: true, children: log.progressText }),
849
942
  log.transientText.length > 0 && /* @__PURE__ */ jsx(Text, { children: stripAssistantLabel(log.transientText) })
850
943
  ] }),
944
+ showSpinner && /* @__PURE__ */ jsx(Spinner, { since: log.lastActivityAt, hasProgress: log.progressText.length > 0, quietOnly: log.transientText.length > 0 }),
851
945
  /* @__PURE__ */ jsx(Text, { dimColor: true, children: "─".repeat(Math.max(1, width)) }),
852
- (pending == null ? void 0 : pending.options) ? /* @__PURE__ */ jsx(SelectPrompt, { question: pending.question, options: pending.options, onSubmit: handleSubmitPrompt }) : /* @__PURE__ */ jsx(
946
+ (pending == null ? void 0 : pending.options) ? /* @__PURE__ */ jsx(SelectPrompt, { question: pending.question, options: pending.options, onSubmit: handleSubmitPrompt, armDelayMs: SELECT_ARM_DELAY_MS }) : /* @__PURE__ */ jsx(
853
947
  TuiInput,
854
948
  {
855
949
  promptLabel: pending == null ? void 0 : pending.question,
@@ -883,6 +977,7 @@ async function runTuiApp(options = {}) {
883
977
  const prompt = new PromptBridge();
884
978
  const status = new StatusBridge();
885
979
  const originalWrite = process.stdout.write.bind(process.stdout);
980
+ const activity = new TurnActivityTracker(eventLog);
886
981
  const restoreCapture = startCapture((event) => eventLog.handleEvent(event));
887
982
  const planGraph = new PlanGraphBridge((chunk) => void originalWrite(chunk));
888
983
  let instanceRef;
@@ -891,6 +986,7 @@ async function runTuiApp(options = {}) {
891
986
  const inert = createInertStreams();
892
987
  instance = await runCli({
893
988
  ...options,
989
+ onTurnBusyChange: (busy) => activity.setCliBusy(busy),
894
990
  openPlanGraph: (nodes) => void planGraph.open(nodes),
895
991
  onPlanProgress: (tasks) => {
896
992
  if (planGraph.isOpen()) void (instanceRef == null ? void 0 : instanceRef.getPlanGraphNodes(tasks).then((nodes) => nodes && planGraph.update(nodes)));
@@ -915,7 +1011,10 @@ async function runTuiApp(options = {}) {
915
1011
  };
916
1012
  await refreshStatus();
917
1013
  const handleSubmitChat = (line) => {
918
- void instance.dispatchLine(line).then(refreshStatus);
1014
+ void activity.track(async () => {
1015
+ await instance.dispatchLine(line);
1016
+ await refreshStatus();
1017
+ });
919
1018
  };
920
1019
  let exiting = false;
921
1020
  const exit = () => {
@@ -946,6 +1045,8 @@ export {
946
1045
  PromptBridge,
947
1046
  StatusBridge,
948
1047
  TuiApp,
1048
+ TurnActivityTracker,
1049
+ formatLivenessLabel,
949
1050
  runTuiApp
950
1051
  };
951
- //# sourceMappingURL=tui-app-BLR-PRFp.js.map
1052
+ //# sourceMappingURL=tui-app-C0J6HRzv.js.map