indusagi-coding-agent 0.2.12 → 0.2.14

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 (81) hide show
  1. package/.env.review.example +25 -0
  2. package/README.md +30 -0
  3. package/REVIEW_AGENT.md +137 -0
  4. package/dist/entry.js +1861 -979
  5. package/dist/guardrails.js +212 -36
  6. package/dist/index.js +2829 -1057
  7. package/dist/review-agent.js +4461 -0
  8. package/dist/review-server.js +4633 -0
  9. package/package.json +24 -7
  10. package/src/boot/auth-vault.ts +4 -5
  11. package/src/boot/contract.ts +1 -1
  12. package/src/boot/runners/addon-wiring.ts +111 -3
  13. package/src/boot/runners/benchmark-integrity.ts +107 -0
  14. package/src/boot/runners/repl-runner.ts +2 -1
  15. package/src/boot/runners/session.ts +26 -12
  16. package/src/boot/server-token.ts +20 -11
  17. package/src/boot/stages.ts +3 -6
  18. package/src/briefing/context-docs.ts +144 -12
  19. package/src/channels/oneshot.ts +6 -0
  20. package/src/conductor/catalog/catalog.ts +3 -1
  21. package/src/conductor/conductor.ts +77 -17
  22. package/src/conductor/contract.ts +9 -7
  23. package/src/conductor/index.ts +1 -1
  24. package/src/conductor/transcript-store/store.ts +4 -8
  25. package/src/console/auth-status.ts +9 -4
  26. package/src/console/components/AgentsView.ts +2 -2
  27. package/src/console/components/BackgroundAgents.ts +15 -2
  28. package/src/console/components/Banner.ts +20 -9
  29. package/src/console/components/Composer.ts +57 -82
  30. package/src/console/components/ScrollViewport.ts +46 -0
  31. package/src/console/components/TerminalConsole.ts +165 -162
  32. package/src/console/components/WorkingIndicator.ts +6 -5
  33. package/src/console/components/banner-sweep.ts +5 -1
  34. package/src/console/components/welcome.ts +3 -3
  35. package/src/console/contract.ts +10 -5
  36. package/src/console/index.ts +7 -7
  37. package/src/console/input/complete.ts +2 -2
  38. package/src/console/input/dir-reader.ts +3 -1
  39. package/src/console/input/graphemes.ts +22 -0
  40. package/src/console/input/keymap.ts +6 -4
  41. package/src/console/mount.ts +45 -3
  42. package/src/console/overlays/auth.ts +28 -8
  43. package/src/console/overlays/boards.ts +13 -40
  44. package/src/console/overlays/host.ts +10 -0
  45. package/src/console/overlays/pickers.ts +35 -30
  46. package/src/console/reducer.ts +12 -5
  47. package/src/console/slash/builtins.ts +4 -3
  48. package/src/console/slash/commands/integrations.ts +13 -14
  49. package/src/console/slash/commands/transcript.ts +3 -8
  50. package/src/console/slash/commands/workbench.ts +4 -4
  51. package/src/console/test-support.ts +48 -0
  52. package/src/index.ts +1 -0
  53. package/src/integrations/sarvam/config.ts +15 -13
  54. package/src/kit/external-editor.ts +17 -1
  55. package/src/kit/run-shell.ts +31 -0
  56. package/src/launch/catalog.ts +4 -2
  57. package/src/launch/contract.ts +1 -0
  58. package/src/launch/invocation/flags.ts +13 -1
  59. package/src/launch/invocation/read.ts +4 -3
  60. package/src/launch/login.ts +23 -10
  61. package/src/launch/oauth.ts +3 -0
  62. package/src/models.ts +166 -0
  63. package/src/review-agent/config.ts +75 -0
  64. package/src/review-agent/contract.ts +59 -0
  65. package/src/review-agent/github.ts +242 -0
  66. package/src/review-agent/index.ts +8 -0
  67. package/src/review-agent/prompt.ts +132 -0
  68. package/src/review-agent/reviewer.ts +118 -0
  69. package/src/review-agent/server.ts +221 -0
  70. package/src/review-agent/service.ts +165 -0
  71. package/src/review-agent/supabase.ts +209 -0
  72. package/src/review-agent/webhook.ts +76 -0
  73. package/src/security/private-state.ts +122 -0
  74. package/src/window-budget/summarize/condense.ts +4 -1
  75. package/src/workspace/brand.ts +1 -1
  76. package/src/workspace/locator.ts +2 -2
  77. package/supabase/migrations/20260827031803_indusagi_review_agent.sql +109 -0
  78. package/supabase/migrations/20260827035747_atomic_webhook_enqueue.sql +75 -0
  79. package/supabase/migrations/20260827040104_check_run_reconciliation.sql +8 -0
  80. package/supabase/migrations/20260827041049_owned_job_leases.sql +80 -0
  81. package/supabase/migrations/20260827042500_static_token_webhooks.sql +75 -0
@@ -5,6 +5,7 @@ import { execFileSync } from "node:child_process";
5
5
  import { homedir } from "node:os";
6
6
  import React, { useEffect, useMemo, useState } from "indusagi/react-host";
7
7
  import { Box, Text, useInput } from "indusagi/react-host/ink";
8
+ import { useStdin, useStdout } from "ink";
8
9
  import { MessageList, StreamingMarkdown, TaskPanel, type InkThemeAdapter } from "indusagi/react-ink";
9
10
  import { Fragment, jsx, jsxs } from "indusagi/react-host/jsx-runtime";
10
11
  import { VERSION } from "../../index.js";
@@ -41,7 +42,10 @@ import {
41
42
  EMPTY_APPROVAL_QUEUE,
42
43
  modeReleasesApprovals
43
44
  } from "../overlays/approval-queue.js";
45
+ import type { ApprovalChoice } from "../../conductor/index.js";
44
46
  import type { ConsoleProps } from "../contract.js";
47
+ import { isThemeScheme } from "../contract.js";
48
+ import { resolveTheme } from "../theme/resolve.js";
45
49
  import { OverlayHost } from "../overlays/host.js";
46
50
  import { useAuthenticatedProviders } from "../auth-status.js";
47
51
  import { gatherStartup } from "../startup.js";
@@ -52,6 +56,7 @@ import { Banner } from "./Banner.js";
52
56
  import { Composer } from "./Composer.js";
53
57
  import { StatusBar } from "./StatusBar.js";
54
58
  import { WorkingIndicator } from "./WorkingIndicator.js";
59
+ import { ScrollViewport, useTerminalSize } from "./ScrollViewport.js";
55
60
 
56
61
  const { useReducer, useRef, useCallback } = React;
57
62
 
@@ -220,8 +225,27 @@ function permissionModeStatusText(mode: string): string {
220
225
 
221
226
  export function TerminalConsole(props: ConsoleProps) {
222
227
  const { conductor, theme, slash, verbose, onExit, initialInput, services } = props;
223
- const adapter = theme.adapter;
224
- const [state, dispatch] = useReducer(consoleReducer, initConsoleState({ scheme: theme.scheme }));
228
+ const {stdin, internal_eventEmitter} = useStdin();
229
+ const {stdout} = useStdout();
230
+ // Ink 5 labels both DEL (the usual Backspace byte) and CSI 3~ as delete.
231
+ // Preserve the raw chunk before Ink's parser discards that distinction.
232
+ const rawKey = useRef("");
233
+ useEffect(() => {
234
+ const capture = (data: string | Buffer) => { rawKey.current = String(data); };
235
+ internal_eventEmitter.prependListener("input", capture);
236
+ return () => { internal_eventEmitter.removeListener("input", capture); };
237
+ }, [internal_eventEmitter]);
238
+ const [state, dispatch] = useReducer(consoleReducer, theme.scheme, (scheme) => {
239
+ let stored: unknown;
240
+ try { stored = services?.settings.get("colourScheme"); } catch { /* use host theme */ }
241
+ const flag = (key: "showReasoning" | "showImages", fallback: boolean) => {
242
+ try { const value = services?.settings.get(key); return typeof value === "boolean" ? value : fallback; }
243
+ catch { return fallback; }
244
+ };
245
+ return initConsoleState({ scheme: typeof stored === "string" && isThemeScheme(stored) ? stored : scheme,
246
+ showReasoning: flag("showReasoning", false), showImages: flag("showImages", true) });
247
+ });
248
+ const adapter = resolveTheme(state.scheme).adapter;
225
249
  const chord = useRef(NO_CHORD);
226
250
  const chordTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
227
251
  const paste = useRef(EMPTY_PASTE_VAULT);
@@ -232,13 +256,20 @@ export function TerminalConsole(props: ConsoleProps) {
232
256
  const liveReason = useRef<string | null>(null);
233
257
  const rowSeed = useRef(0);
234
258
  const ctrlCAt = useRef(0);
259
+ const resumeHandler = useRef<(() => void) | null>(null);
260
+ useEffect(() => () => { if (resumeHandler.current) process.off("SIGCONT", resumeHandler.current); }, []);
235
261
  const [highlight, setHighlight] = useState(0);
236
262
  const [toolExecutions, setToolExecutions] = useState<Record<string, any>>({});
237
263
  const [taskTokens, setTaskTokens] = useState<Record<string, number>>({});
238
264
  const [subagentActivity, setSubagentActivity] = useState<Record<string, any[]>>({});
239
265
  const [expandTools, setExpandTools] = useState(false);
240
266
  const [agentsOpen, setAgentsOpen] = useState(false);
241
- const [terminalRows, setTerminalRows] = useState(() => process.stdout.rows ?? FALLBACK_TERMINAL_ROWS);
267
+ const { rows: terminalRows, columns: terminalColumns } = useTerminalSize();
268
+ // Ink clears the entire terminal whenever rendered output is as tall as the
269
+ // terminal (`outputHeight >= stdout.rows`). Leave one physical row unused so
270
+ // ordinary input edits use Ink's incremental line updates instead of a
271
+ // full-screen clear, which visibly flickers over Windows Terminal/SSH.
272
+ const renderRows = Math.max(1, terminalRows - 1);
242
273
  const [, forceTick] = useState(0);
243
274
  const pulse = useCallback(() => forceTick((n) => n + 1), []);
244
275
  const dirReader = useMemo(() => createDirReader(process.cwd()), []);
@@ -260,7 +291,7 @@ export function TerminalConsole(props: ConsoleProps) {
260
291
  }, []);
261
292
  const requestApproval = useCallback(
262
293
  (toolName: string, input: unknown, opts: { signal?: AbortSignal }) =>
263
- new Promise((resolve) => {
294
+ new Promise<ApprovalChoice>((resolve) => {
264
295
  approvalSeed.current += 1;
265
296
  const id = `ap_${approvalSeed.current}`;
266
297
  const request = {
@@ -268,17 +299,19 @@ export function TerminalConsole(props: ConsoleProps) {
268
299
  input,
269
300
  suggestions: allowAlwaysRuleStrings(toolName, input)
270
301
  };
271
- approvalResolvers.current.set(id, resolve);
272
302
  const signal = opts.signal;
303
+ const onAbort = () => settleApproval(id, "deny");
304
+ const finish = (choice: ApprovalChoice) => { signal?.removeEventListener("abort", onAbort); resolve(choice); };
305
+ approvalResolvers.current.set(id, finish);
273
306
  if (signal !== void 0) {
274
307
  if (signal.aborted) {
275
308
  approvalResolvers.current.delete(id);
276
309
  resolve("deny");
277
310
  return;
278
311
  }
279
- signal.addEventListener("abort", () => settleApproval(id, "deny"), { once: true });
312
+ signal.addEventListener("abort", onAbort, { once: true });
280
313
  }
281
- dispatchApproval({ type: "enqueue", entry: { id, request, resolve } });
314
+ dispatchApproval({ type: "enqueue", entry: { id, request, resolve: finish } });
282
315
  }),
283
316
  [settleApproval]
284
317
  );
@@ -324,10 +357,31 @@ export function TerminalConsole(props: ConsoleProps) {
324
357
  const reasonText = useRef("");
325
358
  const visibleFrom = useRef(0);
326
359
  const [compactNote, setCompactNote] = useState<string[] | null>(null);
360
+ const sessionId = conductor.snapshot().head.sessionId;
361
+ const priorSession = useRef(sessionId);
362
+ const resetView = useCallback(() => {
363
+ liveAnswer.current = null; liveReason.current = null;
364
+ answerText.current = ""; reasonText.current = ""; visibleFrom.current = 0;
365
+ activePaste.current = null; paste.current = EMPTY_PASTE_VAULT; burst.current = "";
366
+ if (burstTimer.current !== null) { clearTimeout(burstTimer.current); burstTimer.current = null; }
367
+ setToolExecutions({}); setTaskTokens({}); setSubagentActivity({}); setCompactNote(null); setAgentsOpen(false);
368
+ denyAllApprovals(); dispatch({type:"view:reset"});
369
+ }, [denyAllApprovals]);
370
+ useEffect(() => {
371
+ if (priorSession.current !== sessionId) { priorSession.current = sessionId; resetView(); }
372
+ }, [sessionId, resetView]);
327
373
  useEffect(() => {
328
374
  const off = conductor.subscribe((signal: any) => {
329
375
  switch (signal.kind) {
376
+ case "reset": { resetView(); break; }
377
+ case "phase": {
378
+ dispatch({type:"busy:set",busy:signal.phase === "streaming" || signal.phase === "tooling" || signal.phase === "condensing"});
379
+ break;
380
+ }
330
381
  case "prompt": {
382
+ liveAnswer.current = null; liveReason.current = null;
383
+ answerText.current = ""; reasonText.current = "";
384
+ dispatch({type:"busy:set",busy:true});
331
385
  break;
332
386
  }
333
387
  case "text": {
@@ -335,10 +389,8 @@ export function TerminalConsole(props: ConsoleProps) {
335
389
  const id = freshRowId(rowSeed.current++);
336
390
  liveAnswer.current = id;
337
391
  answerText.current = signal.delta;
338
- dispatch({ type: "rows:append", row: { id, kind: "answer", text: answerText.current } });
339
392
  } else {
340
393
  answerText.current += signal.delta;
341
- dispatch({ type: "rows:patch", id: liveAnswer.current, text: answerText.current });
342
394
  }
343
395
  break;
344
396
  }
@@ -347,19 +399,14 @@ export function TerminalConsole(props: ConsoleProps) {
347
399
  const id = freshRowId(rowSeed.current++);
348
400
  liveReason.current = id;
349
401
  reasonText.current = signal.delta;
350
- dispatch({ type: "rows:append", row: { id, kind: "reason", text: reasonText.current } });
351
402
  } else {
352
403
  reasonText.current += signal.delta;
353
- dispatch({ type: "rows:patch", id: liveReason.current, text: reasonText.current });
354
404
  }
355
405
  break;
356
406
  }
357
407
  case "tool_start": {
358
408
  liveAnswer.current = null;
359
409
  liveReason.current = null;
360
- const id = `tool-${signal.id}`;
361
- const row = { id, kind: "toolRun", text: signal.name, runId: signal.id };
362
- dispatch({ type: "rows:append", row });
363
410
  setToolExecutions((current) => ({
364
411
  ...current,
365
412
  [signal.id]: {
@@ -404,11 +451,6 @@ export function TerminalConsole(props: ConsoleProps) {
404
451
  break;
405
452
  }
406
453
  case "tool_end": {
407
- dispatch({
408
- type: "rows:patch",
409
- id: `tool-${signal.id}`,
410
- text: signal.ok ? "done" : "failed"
411
- });
412
454
  setToolExecutions((current) => {
413
455
  const prior = current[signal.id];
414
456
  if (prior === void 0) return current;
@@ -433,7 +475,7 @@ export function TerminalConsole(props: ConsoleProps) {
433
475
  if (signal.manual) {
434
476
  const carried: string[] = [];
435
477
  for (const message of conductor.messages()) {
436
- const content = message.content;
478
+ const content = "content" in message ? message.content : undefined;
437
479
  if (typeof content === "string" && content.startsWith(RESTORED_FILE_PREFIX)) {
438
480
  const firstLine = content.slice(RESTORED_FILE_PREFIX.length).split("\n", 1)[0];
439
481
  if (firstLine) carried.push(firstLine.trim());
@@ -445,7 +487,7 @@ export function TerminalConsole(props: ConsoleProps) {
445
487
  liveReason.current = null;
446
488
  answerText.current = "";
447
489
  reasonText.current = "";
448
- dispatch({ type: "rows:set", rows: [] });
490
+ dispatch({ type: "blocks:clear" });
449
491
  }
450
492
  dispatch({
451
493
  type: "status:set",
@@ -474,6 +516,7 @@ export function TerminalConsole(props: ConsoleProps) {
474
516
  break;
475
517
  }
476
518
  case "idle": {
519
+ liveAnswer.current = null; liveReason.current = null;
477
520
  dispatch({ type: "busy:set", busy: false });
478
521
  break;
479
522
  }
@@ -483,7 +526,7 @@ export function TerminalConsole(props: ConsoleProps) {
483
526
  pulse();
484
527
  });
485
528
  return off;
486
- }, [conductor, pulse]);
529
+ }, [conductor, pulse, resetView]);
487
530
  const completion = useMemo(
488
531
  () => completeAt(state.buffer, state.caret, slash, dirReader),
489
532
  [state.buffer, state.caret, slash, dirReader]
@@ -494,8 +537,6 @@ export function TerminalConsole(props: ConsoleProps) {
494
537
  }, [suggestions.length, highlight]);
495
538
  const runTurn = useCallback(
496
539
  async (text: string) => {
497
- const id = freshRowId(rowSeed.current++);
498
- dispatch({ type: "rows:append", row: { id, kind: "prompt", text } });
499
540
  dispatch({ type: "busy:set", busy: true });
500
541
  if (conductor.snapshot().phase === "condensing") {
501
542
  dispatch({
@@ -505,8 +546,8 @@ export function TerminalConsole(props: ConsoleProps) {
505
546
  }
506
547
  setCompactNote(null);
507
548
  activePaste.current = null;
508
- answerText.current = "";
509
- reasonText.current = "";
549
+ // The conductor may only enqueue this input. Live buffers belong to the
550
+ // running turn and are reset by actual turn lifecycle signals.
510
551
  try {
511
552
  await conductor.submit(text);
512
553
  } catch (err) {
@@ -525,9 +566,10 @@ export function TerminalConsole(props: ConsoleProps) {
525
566
  const text = hasMarkers(raw, paste.current) ? expandMarkers(raw, paste.current) : raw;
526
567
  const trimmed = text.trim();
527
568
  dispatch({ type: "buffer:set", text: "" });
528
- paste.current = EMPTY_PASTE_VAULT;
569
+ paste.current = { bodies: new Map(), minted: paste.current.minted };
570
+ activePaste.current = null;
529
571
  if (trimmed.length === 0) return;
530
- dispatch({ type: "history:push", text: raw });
572
+ dispatch({ type: "history:push", text });
531
573
  if (trimmed.startsWith("!")) {
532
574
  const excludeFromContext = trimmed.startsWith("!!");
533
575
  const command = trimmed.slice(excludeFromContext ? 2 : 1).trim();
@@ -536,6 +578,13 @@ export function TerminalConsole(props: ConsoleProps) {
536
578
  void conductor
537
579
  .executeBash(command, { excludeFromContext })
538
580
  .then((outcome: any) => {
581
+ const output = String(outcome.output ?? "");
582
+ const fence = "`".repeat(Math.max(3, ...Array.from(output.matchAll(/`+/g), m => m[0].length + 1)));
583
+ const recorded = conductor.messages().some((message: any) => message.role === "user" && message.content === `$ ${command}\n${output}`.trimEnd());
584
+ if (!recorded || excludeFromContext) dispatch({ type: "block:append", block: {
585
+ id: freshRowId(rowSeed.current++), kind: "changelog", title: `$ ${command}`,
586
+ markdown: `${fence}text\n${output || "(no output)"}\n${fence}`, timestamp: Date.now()
587
+ } });
539
588
  dispatch({
540
589
  type: "status:set",
541
590
  status: {
@@ -554,9 +603,9 @@ export function TerminalConsole(props: ConsoleProps) {
554
603
  }
555
604
  const resolution = resolveSlash(trimmed, slash);
556
605
  if (resolution.kind === "match") {
557
- const outcome = resolution.command.run({
606
+ void Promise.resolve().then(() => resolution.command.run({
558
607
  args: resolution.args,
559
- conductor,
608
+ conductor, services,
560
609
  dispatch,
561
610
  openModal: (kind, payload) => dispatch({ type: "modal:open", kind, payload }),
562
611
  closeModal: () => dispatch({ type: "modal:close" }),
@@ -564,10 +613,9 @@ export function TerminalConsole(props: ConsoleProps) {
564
613
  setStatus: (status) => dispatch({ type: "status:set", status }),
565
614
  setBuffer: (value) => dispatch({ type: "buffer:set", text: value }),
566
615
  appendBlock: (block) => dispatch({ type: "block:append", block })
567
- });
568
- void Promise.resolve(outcome).then((settled: any) => {
616
+ })).then((settled: any) => {
569
617
  if (settled.kind === "prompt") void runTurn(settled.text);
570
- });
618
+ }).catch((error) => dispatch({type:"status:set",status:{kind:"error",text:error instanceof Error ? error.message : String(error)}}));
571
619
  return;
572
620
  }
573
621
  if (resolution.kind === "miss") {
@@ -579,7 +627,7 @@ export function TerminalConsole(props: ConsoleProps) {
579
627
  }
580
628
  void runTurn(text);
581
629
  },
582
- [slash, conductor, onExit, runTurn]
630
+ [slash, conductor, onExit, runTurn, services]
583
631
  );
584
632
  const flushBurst = useCallback(() => {
585
633
  if (burstTimer.current !== null) {
@@ -639,13 +687,6 @@ export function TerminalConsole(props: ConsoleProps) {
639
687
  },
640
688
  []
641
689
  );
642
- useEffect(() => {
643
- const onResize = () => setTerminalRows(process.stdout.rows ?? FALLBACK_TERMINAL_ROWS);
644
- process.stdout.on("resize", onResize);
645
- return () => {
646
- process.stdout.off("resize", onResize);
647
- };
648
- }, []);
649
690
  const titleName = conductor.sessionName();
650
691
  useEffect(() => {
651
692
  writeTerminalTitle(titleName && titleName.length > 0 ? titleName : "indus console");
@@ -666,7 +707,8 @@ export function TerminalConsole(props: ConsoleProps) {
666
707
  (rawInput, key) => {
667
708
  const input = rawInput.replace(BRACKETED_PASTE, "");
668
709
  if (input.length === 0 && input !== rawInput) return;
669
- const intent = readKey(input, key);
710
+ const isBackspaceByte = rawKey.current === "\x7f" || rawKey.current === "\b";
711
+ const intent = readKey(input, {...key, backspace:key.backspace || isBackspaceByte, delete:key.delete && !isBackspaceByte});
670
712
  if (intent.verb === "text:type") {
671
713
  burst.current += intent.text ?? "";
672
714
  armChord(NO_CHORD);
@@ -803,7 +845,8 @@ export function TerminalConsole(props: ConsoleProps) {
803
845
  return;
804
846
  }
805
847
  case "flow:cycleModel":
806
- conductor.cycleModel?.("");
848
+ conductor.cycleModel?.("", intent.direction ?? 1);
849
+ pulse();
807
850
  return;
808
851
  case "model:cycleThinking": {
809
852
  const level = conductor.cycleThinkingLevel();
@@ -859,7 +902,7 @@ export function TerminalConsole(props: ConsoleProps) {
859
902
  return;
860
903
  }
861
904
  case "input:externalEditor": {
862
- const edited = openInExternalEditor(state.buffer);
905
+ const edited = openInExternalEditor(expandMarkers(state.buffer, paste.current), {terminal:{stdin,stdout}});
863
906
  if (edited !== null) {
864
907
  dispatch({ type: "buffer:set", text: edited });
865
908
  }
@@ -873,9 +916,25 @@ export function TerminalConsole(props: ConsoleProps) {
873
916
  case "view:expandTools":
874
917
  setExpandTools((current) => !current);
875
918
  return;
876
- case "flow:suspend":
877
- process.kill(process.pid, "SIGTSTP");
919
+ case "flow:suspend": {
920
+ if (process.platform === "win32") {
921
+ dispatch({type:"status:set",status:{kind:"warning",text:"Process suspension is not supported on Windows."}});
922
+ return;
923
+ }
924
+ const wasRaw = stdin.isRaw === true;
925
+ const resume = () => {
926
+ process.off("SIGCONT", resume); resumeHandler.current = null;
927
+ stdin.setRawMode?.(wasRaw);
928
+ if (stdout.isTTY) stdout.write("\x1b[?25l");
929
+ dispatch({type:"status:set",status:{kind:"info",text:"Resumed console."}});
930
+ };
931
+ resumeHandler.current = resume;
932
+ process.once("SIGCONT", resume);
933
+ stdin.setRawMode?.(false);
934
+ if (stdout.isTTY) stdout.write("\x1b[?25h");
935
+ try { process.kill(process.pid, "SIGTSTP"); } catch { resume(); }
878
936
  return;
937
+ }
879
938
  case "none":
880
939
  default:
881
940
  return;
@@ -883,6 +942,14 @@ export function TerminalConsole(props: ConsoleProps) {
883
942
  },
884
943
  { isActive: state.modal.kind === "none" && !agentsOpen }
885
944
  );
945
+ // Global interrupt ownership also applies while a dialog or drill-in has focus.
946
+ useInput((input, key) => {
947
+ if (!key.ctrl || input !== "c") return;
948
+ if (state.busy || conductor.isBusy?.() || conductor.snapshot().phase === "condensing") {
949
+ denyAllApprovals(); conductor.abort();
950
+ }
951
+ dispatch({type:"modal:close"}); setAgentsOpen(false);
952
+ }, {isActive: state.modal.kind !== "none" || agentsOpen});
886
953
  useInput(
887
954
  (_rawInput, key) => {
888
955
  if (key.tab && key.shift) cyclePermission();
@@ -926,7 +993,7 @@ export function TerminalConsole(props: ConsoleProps) {
926
993
  const taskPanelRows = showTaskPanel ? 2 + Math.min(pendingMessages.length, 8) : 0;
927
994
  const workingIndicatorRows = state.busy ? 2 : 0;
928
995
  const maxItems = transcriptHeight(
929
- terminalRows,
996
+ renderRows,
930
997
  modalOpen,
931
998
  agentPanelRows(agentRuns) + streamingAnswerRows + taskPanelRows + workingIndicatorRows
932
999
  );
@@ -963,7 +1030,8 @@ export function TerminalConsole(props: ConsoleProps) {
963
1030
  live = false;
964
1031
  };
965
1032
  }, [services]);
966
- const authedProviders = useAuthenticatedProviders(services, state.status);
1033
+ const authRefreshKey = useMemo(() => ({status:state.status,modal:state.modal.kind}), [state.status,state.modal.kind]);
1034
+ const authedProviders = useAuthenticatedProviders(services, authRefreshKey);
967
1035
  const boundModelId = conductor.snapshot().modelId;
968
1036
  const boundProvider = boundModelId.includes("/") ? boundModelId.split("/")[0] : boundModelId;
969
1037
  const modelAuthed = authedProviders === null || authedProviders.has(boundProvider);
@@ -972,119 +1040,54 @@ export function TerminalConsole(props: ConsoleProps) {
972
1040
  modelAuthed || !snapshot.model
973
1041
  ? snapshot
974
1042
  : { ...snapshot, model: { ...snapshot.model, provider: "no model", id: "run /login" } };
975
- return jsxs(Box, {
976
- flexDirection: "column",
977
- children: [
978
- agentsOpen
979
- ? jsx(AgentsView, {
980
- theme: adapter,
981
- runs: agentRuns,
982
- activity: subagentActivity,
983
- tokens: taskTokens,
984
- onExit: () => setAgentsOpen(false)
985
- })
986
- : jsxs(Fragment, {
987
- children: [
988
- jsx(Banner, {
989
- theme: adapter,
990
- modelId: bannerModelId,
991
- workspace: process.cwd(),
992
- version: VERSION,
993
- verbose,
994
- quiet: quietStartup,
995
- compact: false,
996
- started: liveMessages.length > 0,
997
- branch,
998
- name: welcomeName,
999
- sweep,
1000
- startup: startupMap,
1001
- variantSeed: welcomeSeed
1002
- }),
1003
- compactNote !== null
1004
- ? jsxs(Box, {
1005
- flexDirection: "column",
1006
- borderStyle: "round",
1007
- borderColor: adapter.colors["borderMuted"],
1008
- paddingX: 1,
1009
- marginBottom: 1,
1010
- children: [
1011
- jsx(Text, {
1012
- children: adapter.color("accent", "◆ Context condensed — fresh session, history summarized")
1013
- }),
1014
- compactNote.length > 0
1015
- ? jsxs(Fragment, {
1016
- children: [
1017
- jsx(Text, { children: adapter.muted("Files carried into the new context:") }),
1018
- compactNote.map((file, index) =>
1019
- jsx(Text, { children: adapter.muted(` · ${file}`) }, index)
1020
- )
1021
- ]
1022
- })
1023
- : jsx(Text, {
1024
- children: adapter.muted("No files re-attached; the summary digest carries the history.")
1025
- })
1026
- ]
1027
- })
1028
- : null,
1029
- jsx(MessageList, {
1030
- messages: liveMessages,
1031
- theme: adapter,
1032
- showThinking: state.showReasoning,
1033
- showImages: state.showImages,
1034
- expandToolOutputs: expandTools,
1035
- maxItems,
1036
- displayBlocks: [...state.blocks]
1037
- }),
1038
- liveAnswer.current !== null && answerText.current
1039
- ? jsx(Box, {
1040
- flexDirection: "column",
1041
- marginBottom: 1,
1042
- children: jsx(StreamingMarkdown, { theme: adapter, children: answerText.current })
1043
- })
1044
- : null,
1045
- showTaskPanel
1046
- ? jsx(TaskPanel, {
1047
- theme: adapter,
1048
- snapshot,
1049
- toolExecutions: SHOW_TOOL_ACTIVITY ? toolExecutions : {},
1050
- expandToolOutputs: expandTools,
1051
- pendingMessages
1052
- })
1053
- : null,
1054
- jsx(WorkingIndicator, {
1055
- theme: adapter,
1056
- busy: state.busy || conductor.snapshot().phase === "condensing",
1057
- phase: conductor.snapshot().phase
1058
- }),
1059
- jsx(Composer, {
1060
- theme: adapter,
1061
- buffer: state.buffer,
1062
- caret: state.caret,
1063
- busy: state.busy,
1064
- suggestions,
1065
- highlight
1066
- }),
1067
- jsx(StatusBar, {
1068
- theme: adapter,
1069
- snapshot: statusBarSnapshot,
1070
- branch,
1071
- providerCount,
1072
- status: state.status,
1073
- permissionMode: conductor.permissionMode()
1074
- }),
1075
- jsx(BackgroundAgents, { theme: adapter, runs: agentRuns, tokens: taskTokens })
1076
- ]
1077
- }),
1078
- jsx(OverlayHost, {
1079
- modal: state.modal,
1080
- services,
1081
- theme: adapter,
1082
- dispatch,
1083
- closeModal: () => dispatch({ type: "modal:close" })
1084
- })
1085
- ]
1043
+ return jsx(Box, {
1044
+ flexDirection: "column", width: terminalColumns, height: renderRows, overflow: "hidden",
1045
+ children: modalOpen
1046
+ ? jsx(ScrollViewport, { follow: false, children: jsx(OverlayHost, {
1047
+ modal: state.modal, services, theme: adapter, dispatch,
1048
+ closeModal: () => dispatch({ type: "modal:close" })
1049
+ }) }, state.modal.kind)
1050
+ : agentsOpen
1051
+ ? jsx(ScrollViewport, { follow: false, children: jsx(AgentsView, {
1052
+ theme: adapter, runs: agentRuns, activity: subagentActivity, tokens: taskTokens,
1053
+ onExit: () => setAgentsOpen(false)
1054
+ }) }, "agents")
1055
+ : jsxs(Fragment, { children: [
1056
+ terminalRows >= 18 ? jsx(Box, { flexShrink: 0, flexDirection: "column", children: jsx(Banner, {
1057
+ theme: adapter, modelId: bannerModelId, workspace: process.cwd(), version: VERSION,
1058
+ verbose, quiet: quietStartup, compact: liveMessages.length > 0 || terminalRows < 32,
1059
+ started: liveMessages.length > 0, branch, name: welcomeName, sweep,
1060
+ startup: startupMap, variantSeed: welcomeSeed
1061
+ }) }) : null,
1062
+ jsx(ScrollViewport, { children: jsxs(Fragment, { children: [
1063
+ compactNote !== null ? jsx(Text, { children: adapter.muted(
1064
+ `Context condensed.${compactNote.length ? " Files carried: " + compactNote.join(", ") : ""}`
1065
+ ) }) : null,
1066
+ jsx(MessageList, {
1067
+ messages: liveMessages, theme: adapter, showThinking: state.showReasoning,
1068
+ showImages: state.showImages, expandToolOutputs: expandTools,
1069
+ maxItems: liveMessages.length + state.blocks.length, displayBlocks: [...state.blocks]
1070
+ }),
1071
+ state.showReasoning && liveReason.current !== null && reasonText.current ? jsx(Box, {
1072
+ flexDirection: "column", marginBottom: 1,
1073
+ children: jsx(StreamingMarkdown, {theme: adapter, dim: true, children: reasonText.current})
1074
+ }) : null,
1075
+ liveAnswer.current !== null && answerText.current ? jsx(Box, {
1076
+ flexDirection: "column", marginBottom: 1,
1077
+ children: jsx(StreamingMarkdown, {theme: adapter, children: answerText.current})
1078
+ }) : null,
1079
+ showTaskPanel ? jsx(TaskPanel, {theme: adapter, snapshot,
1080
+ toolExecutions: SHOW_TOOL_ACTIVITY ? toolExecutions : {},
1081
+ expandToolOutputs: expandTools, pendingMessages}) : null,
1082
+ jsx(BackgroundAgents, {theme: adapter, runs: agentRuns, tokens: taskTokens})
1083
+ ] }) }, snapshot.sessionId),
1084
+ jsx(Box, {flexDirection: "column", flexShrink: 0, children: jsxs(Fragment, {children: [
1085
+ jsx(WorkingIndicator, {theme: adapter, busy: state.busy || conductor.snapshot().phase === "condensing", phase: conductor.snapshot().phase, reducedMotion: services?.settings.get("reducedMotion") ?? false}),
1086
+ jsx(Composer, {theme: adapter, buffer: state.buffer, caret: state.caret,
1087
+ busy: state.busy, suggestions, highlight, paddingX: services?.settings.get("editorPaddingX") ?? 1}),
1088
+ terminalRows >= 12 ? jsx(StatusBar, {theme: adapter, snapshot: statusBarSnapshot, branch,
1089
+ providerCount, status: state.status, permissionMode: conductor.permissionMode()}) : null
1090
+ ]})})
1091
+ ]})
1086
1092
  });
1087
1093
  }
1088
-
1089
- // Silence unused warnings for cross-module imports that may not be used directly here.
1090
- void NO_INTENT;
@@ -65,9 +65,10 @@ export interface WorkingIndicatorProps {
65
65
  readonly theme: InkThemeAdapter;
66
66
  readonly busy: boolean;
67
67
  readonly phase?: string;
68
+ readonly reducedMotion?: boolean;
68
69
  }
69
70
 
70
- export function WorkingIndicator({ theme, busy, phase }: WorkingIndicatorProps) {
71
+ export function WorkingIndicator({ theme, busy, phase, reducedMotion = false }: WorkingIndicatorProps) {
71
72
  const [frame, setFrame] = useState(0);
72
73
  const startRef = useRef<number | null>(null);
73
74
  const wordOffsetRef = useRef(0);
@@ -80,14 +81,14 @@ export function WorkingIndicator({ theme, busy, phase }: WorkingIndicatorProps)
80
81
  startRef.current = Date.now();
81
82
  wordOffsetRef.current = Math.floor(Math.random() * WORKING_WORDS.length);
82
83
  }
83
- const id = setInterval(() => setFrame((f) => (f + 1) % SPINNER_FRAMES.length), FRAME_MS);
84
+ const id = setInterval(() => setFrame((f) => (f + 1) % SPINNER_FRAMES.length), reducedMotion ? 1000 : FRAME_MS);
84
85
  return () => clearInterval(id);
85
- }, [busy]);
86
+ }, [busy, reducedMotion]);
86
87
  if (!busy) return null;
87
88
  const elapsedMs = startRef.current !== null ? Date.now() - startRef.current : 0;
88
89
  const elapsedSec = Math.floor(elapsedMs / 1000);
89
- const spinner = SPINNER_FRAMES[frame % SPINNER_FRAMES.length];
90
- const label = phaseLabel(phase) ?? pickWord(wordOffsetRef.current + Math.floor(elapsedMs / WORD_MS));
90
+ const spinner = reducedMotion ? "•" : SPINNER_FRAMES[frame % SPINNER_FRAMES.length];
91
+ const label = phaseLabel(phase) ?? (reducedMotion ? "Working" : pickWord(wordOffsetRef.current + Math.floor(elapsedMs / WORD_MS)));
91
92
  return jsxs(Box, {
92
93
  marginTop: 1,
93
94
  children: [
@@ -20,5 +20,9 @@ export function welcomeLine(name?: string): string {
20
20
  */
21
21
  export function rowGradient(rowIndex: number, rowCount: number, primaryHex: string, secondaryHex: string): string {
22
22
  if (rowCount <= 1) return primaryHex;
23
- return primaryHex;
23
+ const t = Math.max(0, Math.min(1, rowIndex / (rowCount - 1)));
24
+ const a = Number.parseInt(primaryHex.replace("#", ""), 16);
25
+ const b = Number.parseInt(secondaryHex.replace("#", ""), 16);
26
+ const channel = (shift: number) => Math.round(((a >> shift) & 255) * (1 - t) + ((b >> shift) & 255) * t).toString(16).padStart(2, "0");
27
+ return `#${channel(16)}${channel(8)}${channel(0)}`;
24
28
  }
@@ -36,12 +36,12 @@ export function mascotRows(face: MascotFace): readonly (readonly MascotSegment[]
36
36
  }
37
37
 
38
38
  export const TIPS: readonly string[] = [
39
- "Run /init to create a CLAUDE.md file with instructions for IndusCode",
39
+ "Run /help to explore commands and configured skill helpers",
40
40
  'Ask in plain English — try "fix the failing tests" or "explain src/agent.ts"',
41
- "Shift+Tab cycles permission modes: auto → plan → accept-edits",
41
+ "Shift+Tab cycles: default → accept-edits → plan → bypass",
42
42
  "Type @ to attach a file, or paste an image straight into the prompt",
43
43
  "Run /status to check your model, account, and connectivity",
44
- "Use /skills to discover specialized helpers for the task at hand"
44
+ "Use /help to discover specialized commands for the task at hand"
45
45
  ];
46
46
 
47
47
  export const WHATS_NEW: readonly string[] = [