@vascend/talos 0.1.3 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +49 -13
  3. package/dist/core/agent.js +58 -38
  4. package/dist/core/session-naming.d.ts +3 -0
  5. package/dist/core/session-naming.js +43 -0
  6. package/dist/core/session-usage.js +4 -1
  7. package/dist/core/session.d.ts +1 -0
  8. package/dist/core/session.js +9 -3
  9. package/dist/core/storage-health.d.ts +24 -0
  10. package/dist/core/storage-health.js +72 -0
  11. package/dist/core/types.d.ts +17 -0
  12. package/dist/tui/agent-sessions-screen.d.ts +25 -0
  13. package/dist/tui/agent-sessions-screen.js +132 -0
  14. package/dist/tui/agent-sessions.d.ts +39 -0
  15. package/dist/tui/agent-sessions.js +175 -0
  16. package/dist/tui/bottom-input.d.ts +11 -0
  17. package/dist/tui/bottom-input.js +106 -10
  18. package/dist/tui/commands.js +2 -1
  19. package/dist/tui/i18n-catalog.js +39 -1
  20. package/dist/tui/logo.js +3 -0
  21. package/dist/tui/model-picker.d.ts +2 -0
  22. package/dist/tui/model-picker.js +49 -0
  23. package/dist/tui/native-scrollback.d.ts +2 -1
  24. package/dist/tui/native-scrollback.js +26 -6
  25. package/dist/tui/prompt-queue-store.d.ts +1 -0
  26. package/dist/tui/prompt-queue-store.js +4 -0
  27. package/dist/tui/queued-questions.js +2 -4
  28. package/dist/tui/quick-help.d.ts +1 -1
  29. package/dist/tui/quick-help.js +3 -0
  30. package/dist/tui/repl.d.ts +3 -0
  31. package/dist/tui/repl.js +110 -24
  32. package/dist/tui/review-store.d.ts +7 -1
  33. package/dist/tui/review-store.js +36 -4
  34. package/dist/tui/scrollback-archive.d.ts +16 -0
  35. package/dist/tui/scrollback-archive.js +90 -0
  36. package/dist/tui/selector.d.ts +4 -0
  37. package/dist/tui/selector.js +9 -2
  38. package/dist/tui/session-naming-notice.d.ts +14 -0
  39. package/dist/tui/session-naming-notice.js +57 -0
  40. package/dist/tui/session-picker.d.ts +2 -0
  41. package/dist/tui/session-picker.js +32 -0
  42. package/dist/tui/storage-warning.d.ts +2 -0
  43. package/dist/tui/storage-warning.js +35 -0
  44. package/dist/vascend/context-feedback-loop.js +2 -0
  45. package/dist/vascend/context-snapshot.d.ts +1 -0
  46. package/dist/vascend/context-snapshot.js +4 -3
  47. package/dist/vascend/context-window-compiler.d.ts +1 -0
  48. package/dist/vascend/context-window-compiler.js +1 -0
  49. package/dist/vascend/granular-kingdom.js +32 -4
  50. package/dist/vascend/kingdom.js +4 -0
  51. package/dist/vascend/memory-store.d.ts +1 -0
  52. package/dist/vascend/memory-store.js +12 -4
  53. package/dist/vascend/room-memory.d.ts +3 -1
  54. package/dist/vascend/room-memory.js +6 -4
  55. package/package.json +2 -2
package/dist/tui/repl.js CHANGED
@@ -9,6 +9,9 @@ import { createUserInbox, pendingText } from "../core/user-inbox.js";
9
9
  import { renderDiff } from "./diff.js";
10
10
  import { renderMarkdown, wrapPlain } from "./markdown.js";
11
11
  import { SessionUsageState } from "./session-usage.js";
12
+ import { readSessionUsage } from "../core/session-usage.js";
13
+ import { storageHealth } from "../core/storage-health.js";
14
+ import { storageWarning } from "./storage-warning.js";
12
15
  import { ContextMeter, contextDetails } from "./context-meter.js";
13
16
  import { buildUsageFooter, subscriptionDetails, SESSION_USAGE_EXPLANATION } from "./usage-footer.js";
14
17
  import { copyText, lastAssistantText } from "./clipboard-text.js";
@@ -76,6 +79,7 @@ import { VascendPlanStore } from "../vascend/plan-store.js";
76
79
  import { reviewOptions, shouldReview, buildRoomMetricsDigest, buildReviewerPrompt } from "../vascend/reviewer.js";
77
80
  import { Session, listSessions, sessionFilePath } from "../core/session.js";
78
81
  import { sessionTitle } from "../core/session-title.js";
82
+ import { nameSession } from "../core/session-naming.js";
79
83
  import { ToolRegistry } from "../tools/registry.js";
80
84
  import { saveGlobalSettings } from "../core/settings.js";
81
85
  import { autoApproves, nextPermMode, permModeLabel } from "./perm-mode.js";
@@ -324,6 +328,7 @@ export async function startRepl(deps) {
324
328
  stats.restore(harness.config.sessionDir, harness.session.id, harness.session.tree().find(n => n.message.role === "assistant")?.ts);
325
329
  const review = new ReviewStore();
326
330
  review.reset(sessionConversation(harness.session));
331
+ review.sessions.restoreMain(readSessionUsage(harness.config.sessionDir, harness.session.id));
327
332
  const reviewContext = () => {
328
333
  const cost = costTracker.snapshot(harness.config.model);
329
334
  const tasks = vascend.state().kingdom.castles.flatMap(c => c.tasks);
@@ -447,6 +452,7 @@ export async function startRepl(deps) {
447
452
  const resetSessionUi = (ctl) => {
448
453
  ctl.clearConversation?.();
449
454
  review.reset(sessionConversation(harness.session));
455
+ review.sessions.restoreMain(readSessionUsage(harness.config.sessionDir, harness.session.id));
450
456
  imageRegistry.clear();
451
457
  pasteRegistry.reset();
452
458
  ctl.restoreQueue?.();
@@ -671,6 +677,7 @@ export async function startRepl(deps) {
671
677
  const providerId = harness.providers.resolve(harness.config.model)?.provider.id ?? "";
672
678
  return buildUsageFooter(statusData(), theme, stats, providerId, statusOpts()).slice(0, 1);
673
679
  },
680
+ warningLines: () => storageWarning(storageHealth.snapshot(harness.config.sessionDir), terminalColumns(), theme.yellow),
674
681
  completion,
675
682
  hint: slashHint,
676
683
  statusStyle: (s) => theme.dark(s),
@@ -782,7 +789,13 @@ export async function startRepl(deps) {
782
789
  input.write(`\n ${mark} background ${t.id} ` +
783
790
  theme.dim(`[${t.status}${t.exitCode != null ? ` · uscita ${t.exitCode}` : ""}] ${truncate(t.command, 50)} · /bg ${t.id} per l'output\n`));
784
791
  });
785
- await input.start();
792
+ const unsubscribeStorage = storageHealth.subscribe(() => input.refresh());
793
+ try {
794
+ await input.start();
795
+ }
796
+ finally {
797
+ unsubscribeStorage();
798
+ }
786
799
  return;
787
800
  }
788
801
  // Fallback (non-TTY o TALOS_CLASSIC_INPUT=1): loop readline classico inline.
@@ -790,6 +803,13 @@ export async function startRepl(deps) {
790
803
  if (harness.session.head)
791
804
  replaySession(harness.session, { write: text => stdout.write(text) }, theme, terminalColumns());
792
805
  const rl = readline.createInterface({ input: stdin, output: stdout });
806
+ const reportStorage = () => {
807
+ const rows = storageWarning(storageHealth.snapshot(harness.config.sessionDir), terminalColumns(), theme.yellow);
808
+ if (rows.length)
809
+ stdout.write("\n" + rows.join("\n") + "\n");
810
+ };
811
+ const unsubscribeStorage = storageHealth.subscribe(reportStorage);
812
+ reportStorage();
793
813
  const ctl = {
794
814
  review,
795
815
  pause: () => rl.pause(),
@@ -848,6 +868,7 @@ export async function startRepl(deps) {
848
868
  void lineQueue.enqueue(raw);
849
869
  });
850
870
  rl.on("close", () => {
871
+ unsubscribeStorage();
851
872
  lineQueue.close();
852
873
  harness.dispose?.();
853
874
  void harness.host.emit("session_shutdown", {});
@@ -1057,7 +1078,7 @@ async function drive(deps, input, ctl, theme, terminalColumns, stats, signal, ge
1057
1078
  const turnInput = imageParts && imageParts.length
1058
1079
  ? [{ type: "text", text: input }, ...imageParts]
1059
1080
  : input;
1060
- await runTurn({ ...deps.harness.deps(confirm), ask, spawnKingdom, inbox }, turnInput, wrappedSink, signal);
1081
+ await runTurn({ ...deps.harness.deps(confirm), env: { ...env, TALOS_AUTO_TITLE: "1" }, ask, spawnKingdom, inbox }, turnInput, wrappedSink, signal);
1061
1082
  if (turnFailed || signal.aborted)
1062
1083
  return;
1063
1084
  // REVISORE di team: per i task complessi (molte stanze) con stanze fatte e
@@ -1404,7 +1425,7 @@ export function makeSink(theme, terminalColumns, cwd, env, ctl, plan) {
1404
1425
  ctl.write(preview.lines.join("\n") + "\n");
1405
1426
  commandCards.set(event.call.id, { index: ctl.logLength() - preview.lines.length,
1406
1427
  firstRow: preview.firstRow, columns, end: ctl.logLength(), header: preview.lines[0] });
1407
- ctl.setStatus(tr("esegue comando terminale", "running terminal command"));
1428
+ ctl.setStatus(tr("Esecuzione comando", "Running command"));
1408
1429
  ctl.setStatusTool?.(event.call.name);
1409
1430
  break;
1410
1431
  }
@@ -1474,6 +1495,9 @@ export function makeSink(theme, terminalColumns, cwd, env, ctl, plan) {
1474
1495
  ctl.setStatus(tr("elaboro", "working"));
1475
1496
  stopDot(); // ferma l'animazione del bullet: il tool e' finito
1476
1497
  const call = calls.get(event.result.callId);
1498
+ const before = preContent.get(event.result.callId);
1499
+ calls.delete(event.result.callId);
1500
+ preContent.delete(event.result.callId);
1477
1501
  const toolCard = toolCards.get(event.result.callId);
1478
1502
  toolCards.delete(event.result.callId);
1479
1503
  if (event.parentId) {
@@ -1548,7 +1572,7 @@ export function makeSink(theme, terminalColumns, cwd, env, ctl, plan) {
1548
1572
  break;
1549
1573
  }
1550
1574
  if (call && !event.result.isError && call.name === "write_file") {
1551
- ctl.review?.change(call.id, { before: call.input["append"] === true ? "" : preContent.get(call.id),
1575
+ ctl.review?.change(call.id, { before: call.input["append"] === true ? "" : before,
1552
1576
  after: String(call.input["content"] ?? ""), scope: call.input["append"] === true ? "append" : "file" });
1553
1577
  }
1554
1578
  // Token per stanza/castello (solo con vascend attivo). Snapshot a
@@ -1593,13 +1617,24 @@ export function makeSink(theme, terminalColumns, cwd, env, ctl, plan) {
1593
1617
  }
1594
1618
  // Keep the change preview alongside its operation.
1595
1619
  if (call && !event.result.isError) {
1596
- const diff = diffForCall(call, preContent.get(event.result.callId), event.result.output, theme, terminalColumns());
1620
+ const diff = diffForCall(call, before, event.result.output, theme, terminalColumns());
1597
1621
  if (diff) {
1598
1622
  ctl.write(previewDiff(diff, theme, terminalColumns() - 1));
1599
1623
  }
1600
1624
  }
1601
1625
  break;
1602
1626
  }
1627
+ case "session_naming":
1628
+ ctl.setSessionNaming?.(event.phase, event.title);
1629
+ break;
1630
+ case "compacting":
1631
+ stopDot();
1632
+ flushText();
1633
+ ctl.setCompacting?.(true);
1634
+ break;
1635
+ case "compaction_end":
1636
+ ctl.setCompacting?.(false);
1637
+ break;
1603
1638
  case "compacted":
1604
1639
  stopDot();
1605
1640
  flushText();
@@ -2061,6 +2096,7 @@ function sessionOptions(list) {
2061
2096
  label: sessionTitle(s.name ?? "") || s.preview || s.id,
2062
2097
  detail: `${fmtAge(s.updatedAt)} · ${s.messages} msg · ${s.id}`,
2063
2098
  value: s.id,
2099
+ age: fmtAge(s.updatedAt),
2064
2100
  }));
2065
2101
  }
2066
2102
  // Riprende una sessione: la carica nell'harness (le closure vedono il nuovo
@@ -2318,8 +2354,45 @@ async function handleSlashCommand(line, deps, ctl, theme, terminalColumns, theme
2318
2354
  return null;
2319
2355
  }
2320
2356
  case "rename": {
2321
- const title = args.trim();
2322
- if (!title || title.length > 120) {
2357
+ let title = args.trim();
2358
+ if (!title) {
2359
+ const choice = await selectOption({
2360
+ title: tr("Rinomina chat", "Rename chat"),
2361
+ question: true,
2362
+ options: [
2363
+ { key: "generate", label: tr("Rigenera dall'ultimo prompt", "Regenerate from the latest prompt"),
2364
+ detail: tr("Usa il modello selezionato per scegliere il titolo.", "Use the selected model to choose the title."), value: "generate" },
2365
+ { key: "manual", label: tr("Imposta manualmente", "Set manually"),
2366
+ detail: tr("Scrivi il nuovo nome della chat.", "Type the new chat name."), value: "manual" },
2367
+ ],
2368
+ ctl, theme, terminalColumns,
2369
+ });
2370
+ if (!choice || signal.aborted)
2371
+ return null;
2372
+ if (choice.value === "manual") {
2373
+ const entered = await ctl.promptInput(tr("Nome della chat (Invio conferma · Esc annulla)", "Chat name (Enter confirms · Esc cancels)"));
2374
+ if (!entered?.trim() || signal.aborted)
2375
+ return null;
2376
+ title = entered.trim();
2377
+ }
2378
+ }
2379
+ if (!title) {
2380
+ const last = [...harness.session.history()].reverse().find(message => message.role === "user");
2381
+ const prompt = last?.content.filter(part => part.type === "text").map(part => part.text).join(" ") || "";
2382
+ if (!prompt) {
2383
+ notifyStatus(ctl, "Invia prima un messaggio per generare il titolo.");
2384
+ return null;
2385
+ }
2386
+ await nameSession(harness.deps(async () => false), prompt, event => {
2387
+ if (event.type === "usage")
2388
+ stats.observe?.(event);
2389
+ if (event.type === "session_naming")
2390
+ ctl.setSessionNaming?.(event.phase, event.title);
2391
+ }, signal, true);
2392
+ ctl.refresh?.();
2393
+ return null;
2394
+ }
2395
+ if (title.length > 120) {
2323
2396
  ctl.write(theme.dim("\n Usa /rename Titolo della chat (da 1 a 120 caratteri).\n"));
2324
2397
  return null;
2325
2398
  }
@@ -3007,44 +3080,52 @@ async function handleSlashCommand(line, deps, ctl, theme, terminalColumns, theme
3007
3080
  case "vascend-compact":
3008
3081
  case "compact": {
3009
3082
  const hasModel = Boolean(harness.providers.resolve(harness.config.model));
3083
+ let feedback = "Checkpoint salvato; nessun modello configurato per compattare.";
3084
+ let failed = false;
3010
3085
  // 1) Compattazione LLM del contesto della sessione.
3011
3086
  if (hasModel) {
3012
3087
  const meter = stats.context ?? new ContextMeter();
3013
3088
  const before = meter.read(harness).tokens;
3014
- ctl.write(theme.dim(`\n compatto ~${formatTokenCount(before)} token attivi; preservo richiesta, attivita aperte e lavoro recente...\n`));
3015
3089
  const controller = new AbortController();
3016
3090
  const onSigint = () => controller.abort();
3091
+ if (signal.aborted)
3092
+ controller.abort();
3093
+ signal.addEventListener("abort", onSigint, { once: true });
3017
3094
  process.once("SIGINT", onSigint);
3018
3095
  try {
3019
3096
  const done = await compactNow(harness.deps(async () => false), controller.signal, (e) => {
3020
3097
  if (e.type === "usage")
3021
3098
  stats.observe?.(e);
3099
+ if (e.type === "compacting")
3100
+ ctl.setCompacting?.(true);
3101
+ if (e.type === "compaction_end")
3102
+ ctl.setCompacting?.(false);
3022
3103
  }, args || undefined);
3023
3104
  if (done) {
3024
3105
  const after = meter.read(harness);
3025
- const archived = after.archived.reduce((sum, block) => sum + block.tokens, 0);
3026
- ctl.write(theme.green(` attivo ~${formatTokenCount(before)} → ~${formatTokenCount(after.tokens)} token · archivio originale ~${formatTokenCount(archived)}\n`));
3106
+ feedback = `Contesto compattato: ~${formatTokenCount(before)} -> ~${formatTokenCount(after.tokens)} token`;
3107
+ }
3108
+ else {
3109
+ feedback = controller.signal.aborted
3110
+ ? "Compattazione interrotta; contesto conservato."
3111
+ : "Nessuna compattazione necessaria; contesto conservato.";
3027
3112
  }
3028
- if (!done)
3029
- ctl.write(theme.dim(controller.signal.aborted
3030
- ? " compattazione interrotta; contesto conservato.\n"
3031
- : " nessuna compattazione applicata; contesto conservato.\n"));
3032
3113
  }
3033
3114
  catch (err) {
3034
- ctl.write(theme.red(` errore compattazione: ${err.message}\n`));
3115
+ feedback = `Compattazione non riuscita: ${err.message}`;
3116
+ failed = true;
3035
3117
  }
3036
3118
  finally {
3037
3119
  process.removeListener("SIGINT", onSigint);
3120
+ signal.removeEventListener("abort", onSigint);
3121
+ ctl.setCompacting?.(false);
3122
+ ctl.setStatus(null);
3038
3123
  }
3039
3124
  }
3040
- else {
3041
- ctl.write(theme.dim("\n nessun modello configurato: salvo solo il checkpoint Vascend.\n"));
3042
- }
3043
3125
  // 2) Checkpoint Vascend/Danilov sullo stato (gia' compattato) della sessione.
3044
3126
  vascend.loadPlan(harness.config.sessionDir, harness.config.planStoreId ?? harness.config.roomStoreId ?? harness.session.id);
3045
- const checkpoint = vascend.compact(harness.session.history());
3046
- ctl.write(theme.green(" ✓ checkpoint Vascend salvato in .vascend-compact.md\n"));
3047
- ctl.write(theme.dim(` piano e contesto pronti per la ripresa (${checkpoint.text.split("\n").length} righe)\n`));
3127
+ vascend.compact(harness.session.history());
3128
+ notifyStatus(ctl, feedback, failed);
3048
3129
  return null;
3049
3130
  }
3050
3131
  case "tree": {
@@ -3200,9 +3281,11 @@ async function handleSlashCommand(line, deps, ctl, theme, terminalColumns, theme
3200
3281
  return null;
3201
3282
  }
3202
3283
  const selected = await selectOption({
3203
- title: tr("Chat salvate", "Saved chats"),
3284
+ title: "Resume a previous session",
3204
3285
  hint: tr("Scrivi per cercare · Invio apre · Esc torna alla chat corrente", "Type to search · Enter open · Esc return to the current chat"),
3205
3286
  filterable: true,
3287
+ sessionList: true,
3288
+ fullHeight: true,
3206
3289
  options: sessionOptions(list).map((option) => ({ ...option,
3207
3290
  detail: option.detail + (option.value === harness.session.id ? " · " + tr("corrente", "current") : ""),
3208
3291
  })),
@@ -3805,7 +3888,9 @@ async function pickModel(harness, ctl, theme, terminalColumns) {
3805
3888
  }
3806
3889
  const current = canonicalModel(harness.config.model);
3807
3890
  const selected = await selectOption({
3808
- title: "Modelli",
3891
+ title: tr("Modelli", "Models"),
3892
+ modelList: true,
3893
+ fullHeight: true,
3809
3894
  options,
3810
3895
  currentKey: current?.fullModel,
3811
3896
  ctl,
@@ -3862,7 +3947,8 @@ function modelOptions(providers, dynamic) {
3862
3947
  const detail = dynById.get(model)?.detail;
3863
3948
  out.push({
3864
3949
  key: full,
3865
- label: `${spec.label} ${model}`,
3950
+ label: model,
3951
+ group: spec.label,
3866
3952
  detail: `${authSummary(spec.id, authProviders)}${detail ? ` · ${detail}` : ""}`,
3867
3953
  value: { providerId: spec.id, modelId: model, fullModel: full },
3868
3954
  });
@@ -1,4 +1,5 @@
1
1
  import type { AgentEvent, Message, ToolCallPart } from "../core/types.js";
2
+ import { AgentSessions } from "./agent-sessions.js";
2
3
  export type ReviewState = "running" | "ok" | "error" | "stopped" | "info";
3
4
  export type ReviewKind = "tool" | "file" | "team" | "notice";
4
5
  export interface ReviewChange {
@@ -27,7 +28,11 @@ export declare function reviewTitle(name: string): string;
27
28
  export declare function callTarget(call: ToolCallPart): string;
28
29
  export declare class ReviewStore {
29
30
  private readonly capacity;
31
+ private readonly maxChars;
32
+ readonly sessions: AgentSessions;
30
33
  private items;
34
+ private sizes;
35
+ private retainedChars;
31
36
  private callIndex;
32
37
  private actorText;
33
38
  private sequence;
@@ -37,7 +42,7 @@ export declare class ReviewStore {
37
42
  private _running;
38
43
  private _label;
39
44
  private _goal;
40
- constructor(capacity?: number);
45
+ constructor(capacity?: number, maxChars?: number);
41
46
  get revision(): number;
42
47
  get evicted(): number;
43
48
  get running(): boolean;
@@ -57,4 +62,5 @@ export declare class ReviewStore {
57
62
  private callId;
58
63
  private notice;
59
64
  private put;
65
+ private remove;
60
66
  }
@@ -1,4 +1,5 @@
1
1
  import { oneLine } from "./terminal-text.js";
2
+ import { AgentSessions } from "./agent-sessions.js";
2
3
  const LABELS = {
3
4
  read_file: "Lettura", list_dir: "Cartella", grep: "Ricerca", glob: "Ricerca file",
4
5
  write_file: "Scrittura", edit_file: "Modifica", bash: "Comando", bash_bg: "Comando in background",
@@ -9,6 +10,14 @@ const LABELS = {
9
10
  kingdom_send: "Messaggio al team", kingdom_inbox: "Messaggi del team", kingdom_wait: "Attesa collaboratori",
10
11
  };
11
12
  export function reviewTitle(name) { return LABELS[name] ?? name; }
13
+ function textSize(value, seen = new Set()) {
14
+ if (typeof value === "string")
15
+ return value.length;
16
+ if (!value || typeof value !== "object" || seen.has(value))
17
+ return 0;
18
+ seen.add(value);
19
+ return Object.values(value).reduce((size, child) => size + textSize(child, seen), 0);
20
+ }
12
21
  export function callTarget(call) {
13
22
  const i = call.input;
14
23
  return oneLine(String(i["path"] ?? i["command"] ?? i["pattern"] ?? i["query"] ??
@@ -18,7 +27,11 @@ export function callTarget(call) {
18
27
  // della chat viene compattato. Nessuna lettura del filesystem dal renderer.
19
28
  export class ReviewStore {
20
29
  capacity;
30
+ maxChars;
31
+ sessions = new AgentSessions();
21
32
  items = new Map();
33
+ sizes = new Map();
34
+ retainedChars = 0;
22
35
  callIndex = new Map();
23
36
  actorText = new Map();
24
37
  sequence = 0;
@@ -28,8 +41,9 @@ export class ReviewStore {
28
41
  _running = false;
29
42
  _label = "Pronto";
30
43
  _goal = "";
31
- constructor(capacity = 2000) {
44
+ constructor(capacity = 2000, maxChars = 8_000_000) {
32
45
  this.capacity = capacity;
46
+ this.maxChars = maxChars;
33
47
  }
34
48
  get revision() { return this._revision; }
35
49
  get evicted() { return this._evicted; }
@@ -39,6 +53,7 @@ export class ReviewStore {
39
53
  entries() { return [...this.items.values()]; }
40
54
  get(id) { return this.items.get(id); }
41
55
  begin(goal) {
56
+ this.sessions.begin(goal);
42
57
  this.run += 1;
43
58
  this._running = true;
44
59
  this._label = "Avvio";
@@ -46,6 +61,7 @@ export class ReviewStore {
46
61
  this._revision += 1;
47
62
  }
48
63
  finish(interrupted = false) {
64
+ this.sessions.finish();
49
65
  for (const entry of this.items.values()) {
50
66
  if (entry.state === "running" && entry.actor === "Talos")
51
67
  this.put({ ...entry, state: "stopped", endedAt: Date.now() });
@@ -55,7 +71,10 @@ export class ReviewStore {
55
71
  this._revision += 1;
56
72
  }
57
73
  reset(history = []) {
74
+ this.sessions.reset();
58
75
  this.items.clear();
76
+ this.sizes.clear();
77
+ this.retainedChars = 0;
59
78
  this.callIndex.clear();
60
79
  this.actorText.clear();
61
80
  this.sequence = 0;
@@ -87,6 +106,7 @@ export class ReviewStore {
87
106
  this.put({ ...entry, change });
88
107
  }
89
108
  record(event, at = Date.now(), actor = "Talos") {
109
+ this.sessions.record(event, at, actor);
90
110
  switch (event.type) {
91
111
  case "status":
92
112
  this._label = oneLine(event.label);
@@ -149,6 +169,8 @@ export class ReviewStore {
149
169
  }
150
170
  }
151
171
  recordActor(actor, event) {
172
+ if (event.type === "status" || event.type === "text")
173
+ this.sessions.record(event, Date.now(), actor);
152
174
  if (event.type === "status") {
153
175
  this.put({ id: `status:${this.run}:${actor}`, kind: "notice", actor, title: "Stato",
154
176
  target: oneLine(event.label), output: event.label, state: "info", startedAt: Date.now() });
@@ -166,7 +188,7 @@ export class ReviewStore {
166
188
  if (text?.trim())
167
189
  this.notice("Aggiornamento", text, "info", Date.now(), actor);
168
190
  this.actorText.delete(actor);
169
- this.items.delete(`stream:${this.run}:${actor}`);
191
+ this.remove(`stream:${this.run}:${actor}`);
170
192
  }
171
193
  if (event.type === "turn_start" || event.type === "done" || event.type === "error") {
172
194
  this.record({ type: "kingdom", phase: "vassal", slug: actor,
@@ -254,17 +276,27 @@ export class ReviewStore {
254
276
  this.put({ id: `notice:${++this.sequence}`, kind: "notice", title, target: oneLine(output).slice(0, 200), actor, state, output, startedAt: at });
255
277
  }
256
278
  put(entry) {
279
+ const size = textSize(entry);
280
+ this.retainedChars += size - (this.sizes.get(entry.id) ?? 0);
281
+ this.sizes.set(entry.id, size);
257
282
  this.items.set(entry.id, entry);
258
- if (this.items.size > this.capacity) {
283
+ while (this.items.size > this.capacity || this.retainedChars > this.maxChars) {
259
284
  const oldest = [...this.items.values()].find(e => e.state !== "running");
260
285
  if (oldest) {
261
- this.items.delete(oldest.id);
286
+ this.remove(oldest.id);
262
287
  for (const [key, id] of this.callIndex)
263
288
  if (id === oldest.id)
264
289
  this.callIndex.delete(key);
265
290
  this._evicted += 1;
266
291
  }
292
+ else
293
+ break;
267
294
  }
268
295
  this._revision += 1;
269
296
  }
297
+ remove(id) {
298
+ this.retainedChars -= this.sizes.get(id) ?? 0;
299
+ this.sizes.delete(id);
300
+ this.items.delete(id);
301
+ }
270
302
  }
@@ -0,0 +1,16 @@
1
+ import type { ScrollbackLine } from "./native-scrollback.js";
2
+ export declare class ScrollbackArchive implements Iterable<ScrollbackLine> {
3
+ private readonly maxChars;
4
+ private rows;
5
+ private chars;
6
+ private directory?;
7
+ private fd?;
8
+ private bytes;
9
+ constructor(maxChars?: number);
10
+ get retainedChars(): number;
11
+ push(line: ScrollbackLine): void;
12
+ private write;
13
+ private spill;
14
+ [Symbol.iterator](): Iterator<ScrollbackLine>;
15
+ clear(): void;
16
+ }
@@ -0,0 +1,90 @@
1
+ import { closeSync, mkdtempSync, openSync, readSync, rmdirSync, unlinkSync, writeSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { StringDecoder } from "node:string_decoder";
5
+ // This extra copy is used for resize, not model context. Spill old rows to
6
+ // disk instead of retaining another complete transcript in the JavaScript heap.
7
+ export class ScrollbackArchive {
8
+ maxChars;
9
+ rows = [];
10
+ chars = 0;
11
+ directory;
12
+ fd;
13
+ bytes = 0;
14
+ constructor(maxChars = 1_000_000) {
15
+ this.maxChars = maxChars;
16
+ }
17
+ get retainedChars() { return this.chars; }
18
+ push(line) {
19
+ if (this.rows.length >= 5000 || this.chars + line.text.length > this.maxChars)
20
+ this.spill();
21
+ if (line.text.length > this.maxChars)
22
+ this.write(line);
23
+ else {
24
+ this.rows.push(line);
25
+ this.chars += line.text.length;
26
+ }
27
+ }
28
+ write(line) {
29
+ if (this.fd === undefined) {
30
+ this.directory = mkdtempSync(join(tmpdir(), "talos-scrollback-"));
31
+ this.fd = openSync(join(this.directory, "rows.jsonl"), "w+", 0o600);
32
+ }
33
+ const data = Buffer.from(JSON.stringify(line) + "\n");
34
+ let offset = 0;
35
+ while (offset < data.length) {
36
+ const written = writeSync(this.fd, data, offset, data.length - offset, this.bytes);
37
+ if (!written)
38
+ throw new Error("Unable to write terminal scrollback");
39
+ offset += written;
40
+ this.bytes += written;
41
+ }
42
+ }
43
+ spill() {
44
+ for (const row of this.rows)
45
+ this.write(row);
46
+ this.rows = [];
47
+ this.chars = 0;
48
+ }
49
+ *[Symbol.iterator]() {
50
+ if (this.fd !== undefined) {
51
+ const buffer = Buffer.allocUnsafe(64 * 1024);
52
+ const decoder = new StringDecoder("utf8");
53
+ let position = 0;
54
+ let pending = "";
55
+ while (position < this.bytes) {
56
+ const size = readSync(this.fd, buffer, 0, Math.min(buffer.length, this.bytes - position), position);
57
+ if (!size)
58
+ throw new Error("Unable to read terminal scrollback");
59
+ position += size;
60
+ pending += decoder.write(buffer.subarray(0, size));
61
+ let start = 0;
62
+ let end;
63
+ while ((end = pending.indexOf("\n", start)) >= 0) {
64
+ yield JSON.parse(pending.slice(start, end));
65
+ start = end + 1;
66
+ }
67
+ pending = pending.slice(start);
68
+ }
69
+ }
70
+ yield* this.rows;
71
+ }
72
+ clear() {
73
+ this.rows = [];
74
+ this.chars = 0;
75
+ this.bytes = 0;
76
+ if (this.fd !== undefined) {
77
+ closeSync(this.fd);
78
+ this.fd = undefined;
79
+ }
80
+ if (this.directory) {
81
+ const directory = this.directory;
82
+ this.directory = undefined;
83
+ try {
84
+ unlinkSync(join(directory, "rows.jsonl"));
85
+ rmdirSync(directory);
86
+ }
87
+ catch { /* A scanner may hold the disposable cache open on Windows. */ }
88
+ }
89
+ }
90
+ }
@@ -6,6 +6,7 @@ export interface SelectOption<T> {
6
6
  readonly group?: string;
7
7
  readonly hint?: string;
8
8
  readonly value: T;
9
+ readonly age?: string;
9
10
  }
10
11
  interface SelectorControl {
11
12
  pause(): void;
@@ -24,6 +25,8 @@ interface SelectorOptions<T> {
24
25
  readonly question?: boolean;
25
26
  readonly selectedDetailsOnly?: boolean;
26
27
  readonly mouse?: boolean;
28
+ readonly sessionList?: boolean;
29
+ readonly modelList?: boolean;
27
30
  }
28
31
  export interface SelectorRenderOptions<T> extends SelectorOptions<T> {
29
32
  readonly index: number;
@@ -33,6 +36,7 @@ export interface SelectorRenderOptions<T> extends SelectorOptions<T> {
33
36
  readonly totalCount?: number;
34
37
  readonly details?: boolean;
35
38
  readonly detailOffset?: number;
39
+ readonly rows?: number;
36
40
  }
37
41
  export declare function pickerPageSize(rows: number, fullHeight?: boolean): number;
38
42
  export declare function filterOptions<T extends {
@@ -6,6 +6,8 @@ import { tr } from "./i18n.js";
6
6
  import { SelectorMouseInput, selectorMouseIndex } from "./selector-mouse.js";
7
7
  import { fitLine, oneLine, padLine, plainText, terminalWidth, wrapText } from "./terminal-text.js";
8
8
  import { renderQuestionChoices } from "./question.js";
9
+ import { renderSessionPicker } from "./session-picker.js";
10
+ import { renderModelPicker } from "./model-picker.js";
9
11
  export function pickerPageSize(rows, fullHeight = false) {
10
12
  const available = Math.max(1, Math.floor(rows) - 6);
11
13
  return Math.min(available, fullHeight ? 26 : 7);
@@ -42,6 +44,10 @@ function detailLines(opts, option, width) {
42
44
  ];
43
45
  }
44
46
  export function renderSelector(opts) {
47
+ if (opts.modelList && !opts.details)
48
+ return renderModelPicker(opts);
49
+ if (opts.sessionList && !opts.details)
50
+ return renderSessionPicker(opts);
45
51
  const t = opts.theme;
46
52
  const width = selectorWidth(opts.columns, opts.wide);
47
53
  const page = Math.max(1, opts.pageSize);
@@ -132,7 +138,8 @@ export async function selectOption(opts) {
132
138
  let renderedRows = 0;
133
139
  const wasRaw = stdin.isRaw;
134
140
  const wasPaused = stdin.isPaused();
135
- const pageSize = () => pickerPageSize(stdout.rows || 24, opts.fullHeight);
141
+ const pageSize = () => opts.modelList ? Math.max(1, (stdout.rows || 24) - 13)
142
+ : opts.sessionList ? Math.max(1, (stdout.rows || 24) - 8) : pickerPageSize(stdout.rows || 24, opts.fullHeight);
136
143
  const detailMax = () => Math.max(0, detailLines(opts, filtered[index], selectorWidth(opts.terminalColumns(), opts.wide) - 2).length - pageSize());
137
144
  const applyFilter = () => {
138
145
  const previous = filtered[index];
@@ -152,7 +159,7 @@ export async function selectOption(opts) {
152
159
  detailOffset = Math.min(detailOffset, detailMax());
153
160
  const lines = renderSelector({ ...opts, options: filtered, index,
154
161
  pageSize: pageSize(), columns: opts.terminalColumns(), query: opts.filterable ? query : undefined,
155
- totalCount: opts.options.length, details, detailOffset,
162
+ totalCount: opts.options.length, details, detailOffset, rows: stdout.rows || 24,
156
163
  });
157
164
  renderedRows = lines.length;
158
165
  opts.ctl.setOverlay(lines);
@@ -0,0 +1,14 @@
1
+ export declare class SessionNamingNotice {
2
+ private readonly refresh;
3
+ private title;
4
+ private startedAt;
5
+ private pending;
6
+ private timer?;
7
+ constructor(refresh: () => void);
8
+ show(title: string): void;
9
+ start(): void;
10
+ finish(title: string): void;
11
+ lines(now?: number): readonly string[];
12
+ line(now?: number): string;
13
+ clear(): void;
14
+ }