indusagi-coding-agent 0.2.13 → 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 (56) hide show
  1. package/README.md +19 -0
  2. package/dist/entry.js +1762 -958
  3. package/dist/guardrails.js +145 -35
  4. package/dist/index.js +1810 -1006
  5. package/dist/review-agent.js +344 -60
  6. package/dist/review-server.js +346 -62
  7. package/package.json +12 -3
  8. package/src/boot/auth-vault.ts +4 -5
  9. package/src/boot/runners/addon-wiring.ts +111 -3
  10. package/src/boot/runners/benchmark-integrity.ts +107 -0
  11. package/src/boot/runners/repl-runner.ts +2 -1
  12. package/src/boot/runners/session.ts +18 -8
  13. package/src/boot/server-token.ts +20 -11
  14. package/src/briefing/context-docs.ts +144 -12
  15. package/src/channels/oneshot.ts +6 -0
  16. package/src/conductor/conductor.ts +67 -12
  17. package/src/conductor/contract.ts +4 -3
  18. package/src/conductor/transcript-store/store.ts +4 -8
  19. package/src/console/auth-status.ts +9 -4
  20. package/src/console/components/AgentsView.ts +2 -2
  21. package/src/console/components/BackgroundAgents.ts +15 -2
  22. package/src/console/components/Banner.ts +20 -9
  23. package/src/console/components/Composer.ts +57 -82
  24. package/src/console/components/ScrollViewport.ts +46 -0
  25. package/src/console/components/TerminalConsole.ts +165 -162
  26. package/src/console/components/WorkingIndicator.ts +6 -5
  27. package/src/console/components/banner-sweep.ts +5 -1
  28. package/src/console/components/welcome.ts +3 -3
  29. package/src/console/contract.ts +10 -5
  30. package/src/console/index.ts +7 -7
  31. package/src/console/input/complete.ts +2 -2
  32. package/src/console/input/dir-reader.ts +3 -1
  33. package/src/console/input/graphemes.ts +22 -0
  34. package/src/console/input/keymap.ts +6 -4
  35. package/src/console/mount.ts +45 -3
  36. package/src/console/overlays/auth.ts +28 -8
  37. package/src/console/overlays/boards.ts +13 -40
  38. package/src/console/overlays/host.ts +10 -0
  39. package/src/console/overlays/pickers.ts +35 -30
  40. package/src/console/reducer.ts +12 -5
  41. package/src/console/slash/builtins.ts +4 -3
  42. package/src/console/slash/commands/integrations.ts +13 -14
  43. package/src/console/slash/commands/transcript.ts +3 -8
  44. package/src/console/slash/commands/workbench.ts +2 -2
  45. package/src/console/test-support.ts +48 -0
  46. package/src/integrations/sarvam/config.ts +15 -13
  47. package/src/kit/external-editor.ts +17 -1
  48. package/src/kit/run-shell.ts +31 -0
  49. package/src/launch/invocation/flags.ts +12 -0
  50. package/src/launch/login.ts +23 -10
  51. package/src/launch/oauth.ts +3 -0
  52. package/src/models.ts +78 -2
  53. package/src/security/private-state.ts +122 -0
  54. package/src/window-budget/summarize/condense.ts +4 -1
  55. package/src/workspace/brand.ts +1 -1
  56. package/src/workspace/locator.ts +2 -2
@@ -3,6 +3,7 @@ import type { AgentEvent, AgentMessage, AgentTool, CanUseToolFn, ThinkingLevel }
3
3
  import { createZeroUsage } from "indusagi/ai";
4
4
  import type { Model, Usage } from "indusagi/ai";
5
5
  import * as path from "node:path";
6
+ import { runShell } from "../kit/run-shell.js";
6
7
  import { conductorFault } from "./contract.js";
7
8
  import type { ApprovalResolver, CodingAgentThinkingLevel, ConductorFault, ConductorPhase, ConductorState, PermissionMode, SessionConductor, SessionConductorOptions, SessionHead } from "./contract.js";
8
9
  import { ModelCatalog, ModelMatcher } from "./catalog/index.js";
@@ -28,7 +29,7 @@ export interface AgentLike {
28
29
  setTools(tools: AgentTool[]): void;
29
30
  setThinkingLevel?(level: ThinkingLevel): void;
30
31
  }
31
- export interface CondenseOpts { readonly force?: boolean; readonly model?: Model<any>; readonly contextTokens?: number; readonly signal?: AbortSignal; readonly apiKey?: string }
32
+ export interface CondenseOpts { readonly instructions?: string; readonly force?: boolean; readonly model?: Model<any>; readonly contextTokens?: number; readonly signal?: AbortSignal; readonly apiKey?: string }
32
33
  export type CondenseFn = (messages: AgentMessage[], opts?: CondenseOpts) => AgentMessage[] | Promise<AgentMessage[]>;
33
34
  export const noopCondense: CondenseFn = (messages) => messages;
34
35
  export interface RetryPolicy { readonly maxAttempts: number; readonly baseDelayMs: number }
@@ -134,6 +135,8 @@ class SessionConductorImpl implements SessionConductor {
134
135
  #matcher?: ModelMatcher;
135
136
  #state: ConductorState;
136
137
  #busy = false;
138
+ #activeWork?: Promise<unknown>;
139
+ #shellAbort?: AbortController;
137
140
  #draining = false;
138
141
  #queue: { mode: "steer" | "followUp"; text: string }[] = [];
139
142
  #boundModel?: Model<any>;
@@ -164,12 +167,22 @@ class SessionConductorImpl implements SessionConductor {
164
167
  clearQueue() { this.#queue.length = 0; this.#hub.emit({ kind: "queue", count: 0 }); }
165
168
  dequeueLast() { const item = this.#queue.pop(); if (item) this.#hub.emit({ kind: "queue", count: this.#queue.length }); return item?.text; }
166
169
  steerNow() { const item = this.#queue.pop(); if (!item) return false; this.#queue.unshift({ mode: "steer", text: item.text }); this.agent.abort(); return true; }
167
- abort() { this.clearQueue(); this.#condenseAbort?.abort(); this.agent.abort(); const fault = conductorFault("aborted", "turn aborted by caller"); this.#dispatch({ type: "fault", fault }); this.#hub.emit({ kind: "fault", fault }); this.#hub.emit({ kind: "idle" }); }
170
+ abort() { this.clearQueue(); this.#condenseAbort?.abort(); this.#shellAbort?.abort(); this.agent.abort(); const fault = conductorFault("aborted", "turn aborted by caller"); this.#dispatch({ type: "fault", fault }); this.#hub.emit({ kind: "fault", fault }); this.#hub.emit({ kind: "idle" }); }
168
171
 
169
172
  async submit(input: string) {
170
173
  if (this.#busy) { this.enqueue(input); return this.#state; }
171
174
  this.#busy = true;
172
- try { await this.#runTurn(input); await this.#drainQueue(); return this.#state; } finally { this.#busy = false; }
175
+ const work = (async () => {
176
+ try { await this.#runTurn(input); await this.#drainQueue(); return this.#state; }
177
+ finally { this.#busy = false; }
178
+ })();
179
+ this.#activeWork = work;
180
+ return work;
181
+ }
182
+ async #stopWork() {
183
+ if (!this.#busy) return;
184
+ this.abort();
185
+ await this.#activeWork;
173
186
  }
174
187
  async #drainQueue() { if (this.#draining) return; this.#draining = true; try { while (this.#queue.length) { const index = this.#queue.findIndex((item) => item.mode === "steer"); const item = index >= 0 ? this.#queue.splice(index, 1)[0] : this.#queue.shift()!; this.#hub.emit({ kind: "queue", count: this.#queue.length }); await this.#runTurn(item.text); } } finally { this.#draining = false; } }
175
188
  async #runTurn(input: string) {
@@ -201,24 +214,62 @@ class SessionConductorImpl implements SessionConductor {
201
214
  }
202
215
 
203
216
  selectModel(id: string) { const card = this.#matcher?.resolveCard(id); if (!card) { const fault = conductorFault("model", `no model matches "${id}"`); this.#dispatch({ type: "fault", fault }); this.#hub.emit({ kind: "fault", fault }); return; } this.#applyModel(card); }
204
- cycleModel(id: string) { this.selectModel(id); }
217
+ cycleModel(id: string, direction: -1 | 1 = 1) {
218
+ if (id.trim()) { this.selectModel(id); return; }
219
+ const cards = this.availableModels();
220
+ if (!cards.length) return;
221
+ const at = cards.findIndex(card => card.id === this.#state.modelId);
222
+ const index = at < 0 ? 0 : (at + direction + cards.length) % cards.length;
223
+ this.selectModel(cards[index].id);
224
+ }
205
225
  #applyModel(card: CatalogCard) { const model = withGatewayBaseUrl(card.model, this.#gatewayBaseUrls); this.agent.setModel(model); this.#boundModel = model; this.#dispatch({ type: "model", modelId: card.id }); }
206
226
  updateGatewayBaseUrls(map: Record<string, string>) { this.#gatewayBaseUrls = map; this.selectModel(this.#state.modelId); }
207
227
  registerTools(tools: AgentTool[]) { this.agent.setTools([...this.#deckTools, ...tools]); }
208
228
 
209
- async condense() { if (this.#busy) return "busy" as const; this.#busy = true; this.#condenseAbort = new AbortController(); try { const result = await this.#runCondense(true, this.#condenseAbort.signal); await this.#drainQueue(); return result; } finally { this.#busy = false; this.#condenseAbort = undefined; } }
229
+ async condense(instructions?: string) {
230
+ if (this.#busy) return "busy" as const;
231
+ this.#busy = true; this.#condenseAbort = new AbortController();
232
+ const work = (async () => {
233
+ try { const result = await this.#runCondense(true, this.#condenseAbort!.signal, instructions); await this.#drainQueue(); return result; }
234
+ finally { this.#busy = false; this.#condenseAbort = undefined; }
235
+ })();
236
+ this.#activeWork = work;
237
+ return work;
238
+ }
210
239
  cancelCondense() { this.#condenseAbort?.abort(); }
211
- async #runCondense(force: boolean, signal?: AbortSignal) {
240
+ async #runCondense(force: boolean, signal?: AbortSignal, instructions?: string) {
212
241
  const before = [...this.agent.state.messages]; this.#dispatch({ type: "phase", phase: "condensing" });
213
- try { const after = await (this.deps.condense ?? noopCondense)(before, { force, model: this.#boundModel, contextTokens: this.#state.contextTokens, signal }); if (signal?.aborted) return "cancelled" as const; if (after.length >= before.length) { this.#dispatch({ type: "settled" }); return "nothing" as const; } this.agent.replaceMessages(after); await this.store.reset(); for (let i = 0; i < after.length; i++) await this.store.append(after[i], i === 0 ? "condense" : undefined); this.#dispatch({ type: "head", head: this.store.head }); this.#hub.emit({ kind: "compacted", manual: force }); this.#dispatch({ type: "settled" }); return "condensed" as const; } catch (error) { const fault = conductorFault("overflow", "auto-condense failed", error); this.#dispatch({ type: "fault", fault }); this.#hub.emit({ kind: "fault", fault }); return "failed" as const; }
242
+ try { const after = await (this.deps.condense ?? noopCondense)(before, { force, model: this.#boundModel, contextTokens: this.#state.contextTokens, signal, instructions }); if (signal?.aborted) { this.#dispatch({type:"settled"}); this.#hub.emit({kind:"idle"}); return "cancelled" as const; } if (after.length >= before.length) { this.#dispatch({ type: "settled" }); return "nothing" as const; } this.agent.replaceMessages(after); await this.store.reset(); for (let i = 0; i < after.length; i++) await this.store.append(after[i], i === 0 ? "condense" : undefined); this.#dispatch({ type: "head", head: this.store.head }); this.#hub.emit({ kind: "compacted", manual: force }); this.#dispatch({ type: "settled" }); return "condensed" as const; } catch (error) { const fault = conductorFault("overflow", "auto-condense failed", error); this.#dispatch({ type: "fault", fault }); this.#hub.emit({ kind: "fault", fault }); return "failed" as const; }
214
243
  }
215
244
 
216
245
  async fork(id: string) { await this.#move(id, `cannot fork from "${id}"`); }
217
246
  async navigateTree(id: string) { await this.#move(id, `cannot navigate to "${id}"`); }
218
- async #move(id: string, message: string) { try { await this.store.branchAt(id); this.agent.replaceMessages(this.store.messages()); this.#dispatch({ type: "head", head: this.store.head }); this.#dispatch({ type: "settled" }); this.#hub.emit({ kind: "idle" }); } catch (error) { const fault = conductorFault("persistence", message, error); this.#dispatch({ type: "fault", fault }); this.#hub.emit({ kind: "fault", fault }); } }
247
+ async #move(id: string, message: string) { try { await this.#stopWork(); await this.store.branchAt(id); this.agent.replaceMessages(this.store.messages()); this.#dispatch({ type: "head", head: this.store.head }); this.#dispatch({ type: "settled" }); this.#hub.emit({ kind: "reset" }); this.#hub.emit({ kind: "idle" }); } catch (error) { const fault = conductorFault("persistence", message, error); this.#dispatch({ type: "fault", fault }); this.#hub.emit({ kind: "fault", fault }); } }
219
248
  async restoreCode(id: string) { return this.options.checkpoint?.restore(id) ?? []; }
220
249
  async codeRestoreInfo(id: string) { return { canRestore: this.options.checkpoint?.hasSnapshot(id) ?? false }; }
221
- async executeBash(command: string, opts: { excludeFromContext?: boolean } = {}) { const { spawn } = await import("node:child_process"); const outcome = await new Promise<{ output: string; exitCode: number }>((resolve) => { const child = spawn(command, { cwd: this.#workspace, shell: true }); let output = ""; child.stdout?.on("data", (chunk) => output += String(chunk)); child.stderr?.on("data", (chunk) => output += String(chunk)); child.on("error", (error) => resolve({ output: output + errorText(error), exitCode: 1 })); child.on("close", (code) => resolve({ output, exitCode: code ?? 1 })); }); if (!opts.excludeFromContext) await this.store.append({ role: "user", content: `$ ${command}\n${outcome.output}`.trimEnd(), timestamp: Date.now() } as AgentMessage, "note"); return outcome; }
250
+ async executeBash(command: string, opts: { excludeFromContext?: boolean; signal?: AbortSignal } = {}) {
251
+ if (this.#busy) throw new Error("A turn is running — stop it before running a shell command.");
252
+ this.#busy = true; this.#shellAbort = new AbortController();
253
+ const signal = opts.signal ? AbortSignal.any([opts.signal, this.#shellAbort.signal]) : this.#shellAbort.signal;
254
+ const work = (async () => {
255
+ try {
256
+ this.#dispatch({type:"phase",phase:"tooling"});
257
+ const outcome = await runShell(command, this.#workspace, signal);
258
+ if (!opts.excludeFromContext) {
259
+ const message: AgentMessage = {role:"user",content:`$ ${command}\n${outcome.output}`.trimEnd(),timestamp:Date.now()};
260
+ const entry = await this.store.append(message, "note");
261
+ this.agent.replaceMessages([...this.agent.state.messages, message]);
262
+ this.#dispatch({type:"head",head:this.store.head});
263
+ this.#hub.emit({kind:"persisted",entryId:entry.id});
264
+ }
265
+ this.#dispatch({type:"settled"}); this.#hub.emit({kind:"idle"});
266
+ await this.#drainQueue();
267
+ return outcome;
268
+ } finally { this.#busy = false; this.#shellAbort = undefined; }
269
+ })();
270
+ this.#activeWork = work;
271
+ return work;
272
+ }
222
273
 
223
274
  stats() { const messages = this.agent.state.messages; let userMessages = 0, assistantMessages = 0, toolCalls = 0, toolResults = 0; for (const message of messages) { if (message.role === "user") userMessages++; else if (message.role === "assistant") { assistantMessages++; for (const part of message.content ?? []) if (typeof part === "object" && part && part.type === "toolCall") toolCalls++; } else if (message.role === "toolResult") toolResults++; } const usage = this.#state.usage; return { sessionId: this.#state.head.sessionId, userMessages, assistantMessages, toolCalls, toolResults, totalMessages: messages.length, tokens: { input: usage.input, output: usage.output, cacheRead: usage.cacheRead, cacheWrite: usage.cacheWrite, total: usage.totalTokens }, cost: usage.cost.total }; }
224
275
  costReport() { const stats = this.stats(); return [`Total cost: $${stats.cost.toFixed(4)}`, `Total tokens: ${stats.tokens.total}`, ` Input: ${stats.tokens.input}`, ` Output: ${stats.tokens.output}`, ` Cache read: ${stats.tokens.cacheRead}`, ` Cache write: ${stats.tokens.cacheWrite}`].join("\n"); }
@@ -233,7 +284,11 @@ class SessionConductorImpl implements SessionConductor {
233
284
  cyclePermissionMode() { const current = this.modeHolder.mode === "bypassPermissions" ? "bypass" : this.modeHolder.mode; const next = PERMISSION_CYCLE[(PERMISSION_CYCLE.indexOf(current) + 1) % PERMISSION_CYCLE.length]; this.setPermissionMode(next); return next; }
234
285
  sessionName() { return this.#sessionName; }
235
286
  setSessionName(name: string) { this.#sessionName = name; }
236
- async resume(id: string) { if (!await this.store.load(id)) { const fault = conductorFault("persistence", `no session "${id}" to resume`); this.#dispatch({ type: "fault", fault }); this.#hub.emit({ kind: "fault", fault }); return; } this.agent.replaceMessages(this.store.messages()); this.#state = { ...this.#state, head: this.store.head, usage: this.store.usage ?? createZeroUsage(), phase: "idle" }; this.#hub.emit({ kind: "idle" }); }
237
- async newSession() { this.agent.abort(); this.agent.replaceMessages([]); this.store.startNewSession(newSessionId()); this.#queue.length = 0; this.#sessionName = undefined; this.#state = { phase: "idle", head: this.store.head, usage: createZeroUsage(), contextTokens: 0, modelId: this.#state.modelId }; this.#hub.emit({ kind: "idle" }); }
238
- #dispatch(action: StateAction) { this.#state = reduceState(this.#state, action); }
287
+ async resume(id: string) { await this.#stopWork(); if (!await this.store.load(id)) { const fault = conductorFault("persistence", `no session "${id}" to resume`); this.#dispatch({ type: "fault", fault }); this.#hub.emit({ kind: "fault", fault }); return; } this.agent.replaceMessages(this.store.messages()); this.#state = { ...this.#state, head: this.store.head, usage: this.store.usage ?? createZeroUsage(), phase: "idle" }; this.#hub.emit({kind:"reset"}); this.#hub.emit({ kind: "idle" }); }
288
+ async newSession() { await this.#stopWork(); this.agent.replaceMessages([]); this.store.startNewSession(newSessionId()); this.#queue.length = 0; this.#sessionName = undefined; this.#state = { phase: "idle", head: this.store.head, usage: createZeroUsage(), contextTokens: 0, modelId: this.#state.modelId }; this.#hub.emit({kind:"reset"}); this.#hub.emit({ kind: "idle" }); }
289
+ #dispatch(action: StateAction) {
290
+ const phase = this.#state.phase;
291
+ this.#state = reduceState(this.#state, action);
292
+ if (phase !== this.#state.phase) this.#hub.emit({kind:"phase",phase:this.#state.phase});
293
+ }
239
294
  }
@@ -119,7 +119,7 @@ export function conductorFault(kind: FaultKind, message: string, cause?: unknown
119
119
  * - `queue` — the pending-input queue changed; `count` is its new depth.
120
120
  * - `idle` — the conductor has no in-flight work and is ready for input.
121
121
  */
122
- export type SessionSignal = {
122
+ export type SessionSignal = { readonly kind: "reset" } | { readonly kind: "phase"; readonly phase: ConductorPhase } | {
123
123
  readonly kind: "prompt";
124
124
  readonly text: string;
125
125
  } | {
@@ -370,6 +370,7 @@ export interface SessionStats {
370
370
  }
371
371
  /** Options for {@link SessionConductor.executeBash}. */
372
372
  export interface ExecuteBashOptions {
373
+ readonly signal?: AbortSignal;
373
374
  /**
374
375
  * When `true`, the command's output is *not* recorded as a transcript note,
375
376
  * so it never re-enters the agent's context. Defaults to `false`.
@@ -654,7 +655,7 @@ export interface SessionConductor {
654
655
  * While the condense runs, {@link submit} queues instead of racing it, and the
655
656
  * queue drains once the condense settles.
656
657
  */
657
- condense(): Promise<CondenseOutcome>;
658
+ condense(instructions?: string): Promise<CondenseOutcome>;
658
659
  /**
659
660
  * Cancel an in-flight manual {@link condense} (the `/compact` Esc affordance).
660
661
  * The summarizer's result is discarded and the transcript stays untouched; a
@@ -842,5 +843,5 @@ export interface SessionConductor {
842
843
  *
843
844
  * @param id canonical id of the model to switch to
844
845
  */
845
- cycleModel?(id: string): void;
846
+ cycleModel?(id: string, direction?: -1 | 1): void;
846
847
  }
@@ -4,6 +4,7 @@ import type { Usage } from "indusagi/ai";
4
4
  import { TRANSCRIPT_SCHEMA } from "../contract.js";
5
5
  import type { SessionHead, TranscriptEntry, TranscriptRole } from "../contract.js";
6
6
  import { encodeEntry, encodeHead, parseSessionText, roleForMessage } from "./serialize.js";
7
+ import { appendPrivateFileSync, atomicWritePrivateFileSync, readPrivateFileSync } from "../../security/private-state.js";
7
8
 
8
9
  export interface TranscriptBackend {
9
10
  locate(sessionId: string): string;
@@ -54,18 +55,13 @@ export function fsBackend(dir: string): TranscriptBackend {
54
55
  return {
55
56
  locate: (sessionId) => `${dir}/${sessionId}.ndjson`,
56
57
  read: async (sessionId) => {
57
- const fs = await import("node:fs/promises");
58
- try { return await fs.readFile(await fileOf(sessionId), "utf8"); } catch { return null; }
58
+ return readPrivateFileSync(await fileOf(sessionId));
59
59
  },
60
60
  append: async (sessionId, line) => {
61
- const fs = await import("node:fs/promises");
62
- await fs.mkdir(dir, { recursive: true });
63
- await fs.appendFile(await fileOf(sessionId), `${line}\n`, "utf8");
61
+ appendPrivateFileSync(await fileOf(sessionId), `${line}\n`);
64
62
  },
65
63
  rewrite: async (sessionId, text) => {
66
- const fs = await import("node:fs/promises");
67
- await fs.mkdir(dir, { recursive: true });
68
- await fs.writeFile(await fileOf(sessionId), text.length === 0 ? "" : `${text}\n`, "utf8");
64
+ atomicWritePrivateFileSync(await fileOf(sessionId), text.length === 0 ? "" : `${text}\n`);
69
65
  },
70
66
  };
71
67
  }
@@ -7,6 +7,7 @@
7
7
  * ready to use) both need the exact same answer, so it lives here once.
8
8
  */
9
9
  import { useEffect, useState } from "indusagi/react-host";
10
+ import { getEnvApiKey } from "indusagi/ai";
10
11
  import { hasValidServerToken } from "../boot/server-token.js";
11
12
  import { serverTierProviders } from "../boot/runners/server-mode.js";
12
13
  import type { OverlayServices } from "./contract.js";
@@ -27,10 +28,14 @@ export function useAuthenticatedProviders(services: OverlayServices | undefined,
27
28
  void (async () => {
28
29
  const found = new Set<string>();
29
30
  try {
30
- for (const provider of services.listLoginProviders()) {
31
+ const providers = new Set([
32
+ ...services.listLoginProviders().map(provider => provider.id),
33
+ ...services.conductor.availableModels().map(model => model.provider)
34
+ ]);
35
+ for (const provider of providers) {
31
36
  try {
32
- if ((await services.vault.listAccounts(provider.id)).length > 0) {
33
- found.add(provider.id);
37
+ if (getEnvApiKey(provider) || (await services.vault.listAccounts(provider)).length > 0) {
38
+ found.add(provider);
34
39
  }
35
40
  } catch {
36
41
  // ignore
@@ -51,6 +56,6 @@ export function useAuthenticatedProviders(services: OverlayServices | undefined,
51
56
  return () => {
52
57
  live = false;
53
58
  };
54
- }, [refreshKey]);
59
+ }, [services, refreshKey]);
55
60
  return authed;
56
61
  }
@@ -5,7 +5,7 @@ import { useState } from "indusagi/react-host";
5
5
  import { Box, Text, useInput } from "indusagi/react-host/ink";
6
6
  import { wrapSelectionIndex, type InkThemeAdapter } from "indusagi/react-ink";
7
7
  import { jsx, jsxs } from "indusagi/react-host/jsx-runtime";
8
- import type { AgentRun } from "./BackgroundAgents.js";
8
+ import { useAgentClock, type AgentRun } from "./BackgroundAgents.js";
9
9
 
10
10
  function formatTokens(tokens: number): string {
11
11
  return tokens >= 1e3 ? `${(tokens / 1e3).toFixed(1)}k` : String(tokens);
@@ -66,7 +66,7 @@ export interface AgentsViewProps {
66
66
  }
67
67
 
68
68
  export function AgentsView({ theme, runs, activity, tokens, now, onExit }: AgentsViewProps) {
69
- const clock = now ?? Date.now();
69
+ const clock = useAgentClock(runs.some(run => run.status === "running"), now);
70
70
  const [selectedIndex, setSelectedIndex] = useState(runs.length > 0 ? 1 : 0);
71
71
  const rowCount = runs.length + 1;
72
72
  const index = Math.min(selectedIndex, rowCount - 1);
@@ -2,6 +2,7 @@
2
2
  * BackgroundAgents — the live sub-agent panel pinned below the input.
3
3
  */
4
4
  import { Box, Text } from "indusagi/react-host/ink";
5
+ import { useEffect, useState } from "indusagi/react-host";
5
6
  import { splitAssistantMessage, type InkThemeAdapter } from "indusagi/react-ink";
6
7
  import { jsx, jsxs } from "indusagi/react-host/jsx-runtime";
7
8
  import type { AgentMessage } from "indusagi/agent";
@@ -100,11 +101,23 @@ export interface BackgroundAgentsProps {
100
101
  /**
101
102
  * Render the background-agents panel, or nothing when no run is in flight.
102
103
  */
104
+ export function useAgentClock(active: boolean, supplied?: number): number {
105
+ const [now, setNow] = useState(Date.now);
106
+ useEffect(() => {
107
+ if (!active || supplied !== undefined) return;
108
+ const timer = setInterval(() => setNow(Date.now()), 1000);
109
+ return () => clearInterval(timer);
110
+ }, [active, supplied]);
111
+ return supplied ?? now;
112
+ }
113
+
103
114
  export function BackgroundAgents({ theme, runs, tokens, now }: BackgroundAgentsProps) {
104
- const clock = now ?? Date.now();
115
+ const clock = useAgentClock(runs.some(run => run.status === "running"), now);
105
116
  const runningCount = runs.reduce((n, run) => (run.status === "running" ? n + 1 : n), 0);
106
117
  if (runningCount === 0) return null;
107
- const visible = runs.slice(-MAX_VISIBLE_RUNS);
118
+ const running = runs.filter(run => run.status === "running");
119
+ const recentDone = runs.filter(run => run.status !== "running").slice(-Math.max(0, MAX_VISIBLE_RUNS - running.length));
120
+ const visible = [...running, ...recentDone].slice(0, MAX_VISIBLE_RUNS);
108
121
  return jsxs(Box, {
109
122
  flexDirection: "column",
110
123
  marginTop: 1,
@@ -9,7 +9,9 @@ import { basename } from "node:path";
9
9
  import type { InkThemeAdapter } from "indusagi/react-ink";
10
10
  import type { StartupChangelog, StartupMap, StartupNotice } from "../startup.js";
11
11
  import { MASCOTS, WHATS_NEW, pickWelcomeVariant, mascotRows, type MascotFace } from "./welcome.js";
12
- import { welcomeLine } from "./banner-sweep.js";
12
+ import { welcomeLine, rowGradient } from "./banner-sweep.js";
13
+ import chalk from "chalk";
14
+ import { useTerminalSize } from "./ScrollViewport.js";
13
15
 
14
16
  const BRAND = "IndusCode";
15
17
  const COMPACT_EMBLEM = "▛▜▙▟";
@@ -23,6 +25,11 @@ const WORDMARK_ROWS: readonly string[] = [
23
25
  "╚═╝╚═╝ ╚═══╝╚═════╝ ╚═════╝ ╚══════╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝"
24
26
  ];
25
27
 
28
+ function StartupSummary({theme, startup}: {theme: InkThemeAdapter; startup?: StartupMap}) {
29
+ if (!startup?.sections.length) return null;
30
+ return jsx(Text, {wrap:"truncate-end", children:theme.muted(startup.sections.map(section => `${section.title}: ${section.count}`).join(" · "))});
31
+ }
32
+
26
33
  function StartupNotices({ theme, notices }: { theme: InkThemeAdapter; notices: readonly StartupNotice[] }) {
27
34
  if (notices.length === 0) return null;
28
35
  return jsx(Box, {
@@ -98,10 +105,10 @@ function ShellLine({ theme, workspace, branch }: { theme: InkThemeAdapter; works
98
105
  });
99
106
  }
100
107
 
101
- function Wordmark({ theme }: { theme: InkThemeAdapter }) {
108
+ function Wordmark({ theme, sweep }: { theme: InkThemeAdapter; sweep?: boolean }) {
102
109
  return jsx(Box, {
103
110
  flexDirection: "column",
104
- children: WORDMARK_ROWS.map((line, index) => jsx(Text, { children: theme.color("accent", line) }, index))
111
+ children: WORDMARK_ROWS.map((line, index) => jsx(Text, { children: sweep ? chalk.hex(rowGradient(index, WORDMARK_ROWS.length, theme.colors.accent, theme.colors.customMessage))(line) : theme.color("accent", line) }, index))
105
112
  });
106
113
  }
107
114
 
@@ -127,7 +134,7 @@ function paintMascot(theme: InkThemeAdapter, tone: string, text: string) {
127
134
  }
128
135
  }
129
136
 
130
- function Mascot({ theme, face }: { theme: InkThemeAdapter; face: MascotFace }) {
137
+ function Mascot({ theme, face, sweep }: { theme: InkThemeAdapter; face: MascotFace; sweep?: boolean }) {
131
138
  return jsx(Box, {
132
139
  flexDirection: "column",
133
140
  alignItems: "center",
@@ -137,7 +144,7 @@ function Mascot({ theme, face }: { theme: InkThemeAdapter; face: MascotFace }) {
137
144
  Box,
138
145
  {
139
146
  children: row.map((seg, segIndex) =>
140
- jsx(Text, { children: paintMascot(theme, seg.tone, seg.text) }, segIndex)
147
+ jsx(Text, { children: sweep && seg.tone === "head" ? chalk.hex(rowGradient(rowIndex, 5, theme.colors.accent, theme.colors.customMessage))(seg.text) : paintMascot(theme, seg.tone, seg.text) }, segIndex)
141
148
  )
142
149
  },
143
150
  rowIndex
@@ -174,6 +181,7 @@ export interface BannerProps {
174
181
  }
175
182
 
176
183
  export function Banner(props: BannerProps) {
184
+ const { columns } = useTerminalSize();
177
185
  const {
178
186
  theme,
179
187
  modelId,
@@ -187,15 +195,16 @@ export function Banner(props: BannerProps) {
187
195
  variantSeed,
188
196
  whatsNew,
189
197
  notices,
190
- changelog
198
+ changelog, sweep, startup
191
199
  } = props;
192
200
  const noticeList = notices ?? [];
193
- if (quiet || compact) {
201
+ if (quiet || compact || columns < 76) {
194
202
  return jsxs(Box, {
195
203
  flexDirection: "column",
196
204
  marginBottom: 1,
197
205
  children: [
198
206
  jsx(StartupNotices, { theme, notices: noticeList }),
207
+ jsx(StartupSummary, {theme, startup}),
199
208
  jsxs(Box, {
200
209
  children: [
201
210
  compact
@@ -225,8 +234,9 @@ export function Banner(props: BannerProps) {
225
234
  marginBottom: 1,
226
235
  children: [
227
236
  jsx(StartupNotices, { theme, notices: noticeList }),
237
+ jsx(StartupSummary, {theme, startup}),
228
238
  jsx(ShellLine, { theme, workspace, branch }),
229
- jsx(Box, { marginTop: 1, children: jsx(Wordmark, { theme }) }),
239
+ jsx(Box, { marginTop: 1, children: jsx(Wordmark, { theme, sweep }) }),
230
240
  jsxs(Box, {
231
241
  marginTop: 1,
232
242
  flexDirection: "column",
@@ -266,6 +276,7 @@ export function Banner(props: BannerProps) {
266
276
  marginBottom: 1,
267
277
  children: [
268
278
  jsx(StartupNotices, { theme, notices: noticeList }),
279
+ jsx(StartupSummary, {theme, startup}),
269
280
  jsxs(Box, {
270
281
  flexDirection: "column",
271
282
  borderStyle: "round",
@@ -292,7 +303,7 @@ export function Banner(props: BannerProps) {
292
303
  marginRight: 2,
293
304
  children: [
294
305
  jsx(Text, { bold: true, children: theme.color("text", greeting) }),
295
- jsx(Mascot, { theme, face: variant.mascot }),
306
+ jsx(Mascot, { theme, face: variant.mascot, sweep }),
296
307
  jsx(Text, { children: theme.dim(metaLine) }),
297
308
  jsx(Text, { children: theme.dim(shortenWorkspace(workspace)) })
298
309
  ]
@@ -1,29 +1,31 @@
1
- /**
2
- * Composer — the prompt-input surface.
3
- */
4
- import { Box, Text } from "indusagi/react-host/ink";
5
- import { jsx, jsxs } from "indusagi/react-host/jsx-runtime";
6
- import type { InkThemeAdapter } from "indusagi/react-ink";
7
- import type { Suggestion } from "../input/index.js";
1
+ /** Bounded, grapheme-aware editor and selection-following completion menu. */
2
+ import { Box, Text } from 'indusagi/react-host/ink';
3
+ import { jsx, jsxs } from 'indusagi/react-host/jsx-runtime';
4
+ import stringWidth from 'string-width';
5
+ import type { InkThemeAdapter } from 'indusagi/react-ink';
6
+ import type { Suggestion } from '../input/index.js';
7
+ import { snapGrapheme } from '../input/graphemes.js';
8
+ import { useTerminalSize } from './ScrollViewport.js';
8
9
 
9
- const PROMPT_GLYPH = "› ";
10
- const SUGGESTION_WINDOW = 8;
11
- const NAME_COLUMN_CAP = 24;
12
- const NAME_COLUMN_FLOOR = 8;
13
-
14
- function clampName(label: string, width: number): string {
15
- if (label.length <= width) return label;
16
- if (width <= 1) return label.slice(0, width);
17
- return `${label.slice(0, width - 1)}…`;
18
- }
19
-
20
- function bufferWithCaret(buffer: string, caret: number) {
21
- const before = buffer.slice(0, caret);
22
- const under = caret < buffer.length ? buffer[caret] : " ";
23
- const after = caret < buffer.length ? buffer.slice(caret + 1) : "";
24
- return jsxs(Text, {
25
- children: [before, jsx(Text, { inverse: true, children: under }), after]
26
- });
10
+ const segmenter = new Intl.Segmenter(undefined, {granularity:'grapheme'});
11
+ interface Cell { text: string; caret: boolean }
12
+ function editorLines(buffer: string, caret: number, width: number) {
13
+ const lines: Cell[][] = [[]];
14
+ let column = 0;
15
+ caret = snapGrapheme(buffer, caret);
16
+ for (const {segment, index} of segmenter.segment(buffer + ' ')) {
17
+ const cursor = index === caret;
18
+ if (segment === '\n') {
19
+ if (cursor) lines.at(-1)!.push({text:' ', caret:true});
20
+ lines.push([]); column=0; continue;
21
+ }
22
+ const text = segment === '\t' ? ' ' : segment.replace(/[\x00-\x1f\x7f]/g,'');
23
+ const cells = stringWidth(text);
24
+ if (column + cells > width && column > 0) {lines.push([]);column=0;}
25
+ lines.at(-1)!.push({text:cells > width ? '?' : text,caret:cursor});
26
+ column += Math.min(width,cells);
27
+ }
28
+ return lines;
27
29
  }
28
30
 
29
31
  export interface ComposerProps {
@@ -33,62 +35,35 @@ export interface ComposerProps {
33
35
  readonly busy: boolean;
34
36
  readonly suggestions: readonly Suggestion[];
35
37
  readonly highlight: number;
38
+ readonly paddingX?: number;
36
39
  }
37
40
 
38
- export function Composer({ theme, buffer, caret, busy, suggestions, highlight }: ComposerProps) {
39
- const shown = suggestions.slice(0, SUGGESTION_WINDOW);
40
- const borderColor = theme.colors.borderMuted ?? theme.colors.accent ?? "gray";
41
- const nameWidth = shown.length > 0
42
- ? Math.max(NAME_COLUMN_FLOOR, Math.min(NAME_COLUMN_CAP, Math.max(...shown.map((s) => s.label.length))))
43
- : 0;
44
- return jsxs(Box, {
45
- flexDirection: "column",
46
- children: [
47
- jsxs(Box, {
48
- borderStyle: "round",
49
- borderColor,
50
- paddingX: 1,
51
- children: [
52
- jsxs(Box, {
53
- flexGrow: 1,
54
- children: [
55
- jsx(Text, { children: theme.color("customMessage", PROMPT_GLYPH) }),
56
- bufferWithCaret(buffer, caret)
57
- ]
58
- }),
59
- jsx(Text, { children: theme.dim("↵ send") })
60
- ]
61
- }),
62
- shown.length > 0
63
- ? jsx(Box, {
64
- flexDirection: "column",
65
- marginTop: 1,
66
- children: shown.map((suggestion, index) => {
67
- const active = index === highlight;
68
- const name = clampName(suggestion.label, nameWidth);
69
- return jsxs(
70
- Box,
71
- {
72
- children: [
73
- jsx(Box, {
74
- width: 2,
75
- flexShrink: 0,
76
- children: jsx(Text, { children: active ? theme.color("accent", "▸") : " " })
77
- }),
78
- jsx(Box, {
79
- width: nameWidth,
80
- flexShrink: 0,
81
- children: jsx(Text, { bold: active, children: theme.color("customMessage", name) })
82
- }),
83
- jsx(Text, { children: " " }),
84
- jsx(Text, { children: theme.muted(suggestion.detail) })
85
- ]
86
- },
87
- suggestion.value
88
- );
89
- })
90
- })
91
- : null
92
- ]
93
- });
94
- }
41
+ export function Composer({theme,buffer,caret,suggestions,highlight,paddingX=1}: ComposerProps) {
42
+ const {columns,rows} = useTerminalSize();
43
+ const padding = Math.max(0, Math.min(3, Math.floor(paddingX), Math.floor((columns-8)/2)));
44
+ const sendWidth = columns >= 32 ? 7 : 0;
45
+ const width = Math.max(1,columns - 2 - padding*2 - 2 - sendWidth);
46
+ const allLines = editorLines(buffer,caret,width);
47
+ const caretRow = Math.max(0,allLines.findIndex(line=>line.some(cell=>cell.caret)));
48
+ const editorBudget = Math.max(1,Math.min(6,Math.floor(rows/4)));
49
+ const top = Math.max(0,Math.min(caretRow-editorBudget+1,allLines.length-editorBudget));
50
+ const lines = allLines.slice(top,top+editorBudget);
51
+ const budget = Math.max(1,Math.min(8,rows-lines.length-12));
52
+ const start = Math.max(0,Math.min(highlight-budget+1,suggestions.length-budget));
53
+ const shown = suggestions.slice(start,start+budget);
54
+ const nameWidth = Math.max(1,Math.min(24,Math.floor(columns/3)));
55
+ return jsxs(Box,{flexDirection:'column',flexShrink:0,children:[
56
+ jsx(Box,{borderStyle:'round',borderColor:theme.colors.borderMuted??'gray',paddingX:padding,flexDirection:'column',children:
57
+ lines.map((line,index)=>jsxs(Box,{height:1,flexShrink:0,children:[
58
+ jsx(Box,{width:2,flexShrink:0,children:jsx(Text,{children:theme.color('customMessage',index+top===0?'› ':'· ')})}),
59
+ jsx(Box,{width,flexShrink:0,children:jsx(Text,{wrap:'truncate-end',children:line.map((cell,i)=>jsx(Text,{inverse:cell.caret,children:cell.text},i))})}),
60
+ sendWidth ? jsx(Box,{width:sendWidth,flexShrink:0,children:jsx(Text,{children:index===0?theme.dim(' ↵ send'):''})}) : null
61
+ ]},index))
62
+ }),
63
+ shown.length ? jsx(Box,{flexDirection:'column',marginTop:1,children:shown.map((suggestion,index)=>jsxs(Box,{height:1,children:[
64
+ jsx(Box,{width:2,flexShrink:0,children:jsx(Text,{children:start+index===highlight?theme.color('accent','▸'):' '})}),
65
+ jsx(Box,{width:nameWidth,flexShrink:0,children:jsx(Text,{wrap:'truncate-end',bold:start+index===highlight,children:theme.color('customMessage',suggestion.label)})}),
66
+ jsx(Box,{width:Math.max(1,columns-nameWidth-2),children:jsx(Text,{wrap:'truncate-end',children:theme.muted(' '+suggestion.detail)})})
67
+ ]},suggestion.value))}) : null
68
+ ]});
69
+ }
@@ -0,0 +1,46 @@
1
+ import React, { useEffect, useState } from 'indusagi/react-host';
2
+ import { Box, useInput } from 'indusagi/react-host/ink';
3
+ import { measureElement, useStdout, type DOMElement } from 'ink';
4
+ import { jsx } from 'indusagi/react-host/jsx-runtime';
5
+ import type { ReactNode } from 'react';
6
+
7
+ export function useTerminalSize() {
8
+ const { stdout } = useStdout();
9
+ const read = () => ({ columns: Math.max(1, stdout.columns || 80), rows: Math.max(1, stdout.rows || 24) });
10
+ const [size, setSize] = useState(read);
11
+ useEffect(() => {
12
+ const resize = () => setSize(read());
13
+ stdout.on('resize', resize);
14
+ return () => { stdout.off('resize', resize); };
15
+ }, [stdout]);
16
+ return size;
17
+ }
18
+
19
+ /** Row-bounded Ink viewport. Page keys scroll; new output follows only at the bottom. */
20
+ export function ScrollViewport({ children, active = true, follow = true }: { children: ReactNode; active?: boolean; follow?: boolean }) {
21
+ const outer = React.useRef<DOMElement>(null);
22
+ const inner = React.useRef<DOMElement>(null);
23
+ const [metrics, setMetrics] = useState({ height: 1, content: 1 });
24
+ const [offset, setOffset] = useState(0);
25
+ useEffect(() => {
26
+ if (!outer.current || !inner.current) return;
27
+ const height = measureElement(outer.current).height;
28
+ const content = measureElement(inner.current).height;
29
+ if (metrics.height !== height || metrics.content !== content) {
30
+ const oldMax = Math.max(0, metrics.content - metrics.height);
31
+ const newMax = Math.max(0, content - height);
32
+ if (follow) setOffset(n => n === 0 ? 0 : Math.max(0, Math.min(newMax, n + newMax - oldMax)));
33
+ setMetrics({height, content});
34
+ }
35
+ });
36
+ const max = Math.max(0, metrics.content - metrics.height);
37
+ const scroll = Math.min(max, offset);
38
+ useInput((_input, key) => {
39
+ const step = Math.max(1, metrics.height - 2);
40
+ if (key.pageUp) setOffset(n => Math.min(max, n + (follow ? step : -step)));
41
+ if (key.pageDown) setOffset(n => Math.max(0, Math.min(max, n + (follow ? -step : step))));
42
+ }, {isActive: active});
43
+ const top = follow ? Math.max(0, max - scroll) : Math.max(0, scroll);
44
+ return jsx(Box, {ref: outer, flexDirection:'column', flexGrow:1, flexBasis:0, minHeight:1, overflow:'hidden',
45
+ children:jsx(Box, {ref:inner, flexDirection:'column', flexShrink:0, marginTop:-top, children})});
46
+ }