@glbgpt/cli 0.1.0-test.5 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (149) hide show
  1. package/README.md +81 -6
  2. package/dist/src/auth/authClient.d.ts +21 -2
  3. package/dist/src/auth/authClient.js +62 -2
  4. package/dist/src/auth/authClient.js.map +1 -1
  5. package/dist/src/auth/loginFlow.js +5 -2
  6. package/dist/src/auth/loginFlow.js.map +1 -1
  7. package/dist/src/auth/loopbackServer.d.ts +2 -1
  8. package/dist/src/auth/loopbackServer.js +6 -3
  9. package/dist/src/auth/loopbackServer.js.map +1 -1
  10. package/dist/src/commands/accountCommand.js +10 -4
  11. package/dist/src/commands/accountCommand.js.map +1 -1
  12. package/dist/src/commands/configCommand.d.ts +5 -0
  13. package/dist/src/commands/configCommand.js +15 -2
  14. package/dist/src/commands/configCommand.js.map +1 -1
  15. package/dist/src/commands/execCommand.d.ts +6 -0
  16. package/dist/src/commands/execCommand.js +64 -9
  17. package/dist/src/commands/execCommand.js.map +1 -1
  18. package/dist/src/commands/loginCommand.js +9 -6
  19. package/dist/src/commands/loginCommand.js.map +1 -1
  20. package/dist/src/commands/mediaCommand.js +3 -2
  21. package/dist/src/commands/mediaCommand.js.map +1 -1
  22. package/dist/src/commands/skillCommand.d.ts +30 -0
  23. package/dist/src/commands/skillCommand.js +160 -0
  24. package/dist/src/commands/skillCommand.js.map +1 -0
  25. package/dist/src/config/configFile.d.ts +12 -0
  26. package/dist/src/config/configFile.js +12 -0
  27. package/dist/src/config/configFile.js.map +1 -1
  28. package/dist/src/config/env.d.ts +18 -0
  29. package/dist/src/config/env.js +57 -7
  30. package/dist/src/config/env.js.map +1 -1
  31. package/dist/src/core/catalog.d.ts +8 -1
  32. package/dist/src/core/catalog.js +13 -2
  33. package/dist/src/core/catalog.js.map +1 -1
  34. package/dist/src/core/contextPrune.d.ts +13 -0
  35. package/dist/src/core/contextPrune.js +66 -0
  36. package/dist/src/core/contextPrune.js.map +1 -0
  37. package/dist/src/core/httpChatBackend.js +22 -5
  38. package/dist/src/core/httpChatBackend.js.map +1 -1
  39. package/dist/src/core/runChat.d.ts +9 -5
  40. package/dist/src/core/runChat.js +10 -7
  41. package/dist/src/core/runChat.js.map +1 -1
  42. package/dist/src/core/session.d.ts +11 -0
  43. package/dist/src/core/session.js +20 -1
  44. package/dist/src/core/session.js.map +1 -1
  45. package/dist/src/core/toolRegistry.d.ts +16 -3
  46. package/dist/src/core/toolRegistry.js +28 -15
  47. package/dist/src/core/toolRegistry.js.map +1 -1
  48. package/dist/src/core/turnLoop.d.ts +4 -0
  49. package/dist/src/core/turnLoop.js +78 -13
  50. package/dist/src/core/turnLoop.js.map +1 -1
  51. package/dist/src/core/types.d.ts +21 -0
  52. package/dist/src/core/updateCheck.d.ts +46 -0
  53. package/dist/src/core/updateCheck.js +139 -0
  54. package/dist/src/core/updateCheck.js.map +1 -0
  55. package/dist/src/exec/media.d.ts +32 -0
  56. package/dist/src/exec/media.js +70 -0
  57. package/dist/src/exec/media.js.map +1 -0
  58. package/dist/src/exec/output.d.ts +16 -0
  59. package/dist/src/exec/output.js +41 -0
  60. package/dist/src/exec/output.js.map +1 -1
  61. package/dist/src/fs/globTool.d.ts +8 -0
  62. package/dist/src/fs/globTool.js +117 -0
  63. package/dist/src/fs/globTool.js.map +1 -0
  64. package/dist/src/fs/grepTool.d.ts +4 -0
  65. package/dist/src/fs/grepTool.js +146 -0
  66. package/dist/src/fs/grepTool.js.map +1 -0
  67. package/dist/src/fs/index.d.ts +8 -0
  68. package/dist/src/fs/index.js +13 -0
  69. package/dist/src/fs/index.js.map +1 -0
  70. package/dist/src/fs/limits.d.ts +34 -0
  71. package/dist/src/fs/limits.js +74 -0
  72. package/dist/src/fs/limits.js.map +1 -0
  73. package/dist/src/fs/listDirTool.d.ts +2 -0
  74. package/dist/src/fs/listDirTool.js +68 -0
  75. package/dist/src/fs/listDirTool.js.map +1 -0
  76. package/dist/src/fs/readFileTool.d.ts +2 -0
  77. package/dist/src/fs/readFileTool.js +102 -0
  78. package/dist/src/fs/readFileTool.js.map +1 -0
  79. package/dist/src/fs/walk.d.ts +11 -0
  80. package/dist/src/fs/walk.js +38 -0
  81. package/dist/src/fs/walk.js.map +1 -0
  82. package/dist/src/fs/workspaceGuard.d.ts +27 -0
  83. package/dist/src/fs/workspaceGuard.js +96 -0
  84. package/dist/src/fs/workspaceGuard.js.map +1 -0
  85. package/dist/src/index.js +47 -3
  86. package/dist/src/index.js.map +1 -1
  87. package/dist/src/mcp/mediaTools.d.ts +63 -0
  88. package/dist/src/mcp/mediaTools.js +385 -0
  89. package/dist/src/mcp/mediaTools.js.map +1 -0
  90. package/dist/src/mcp/server.d.ts +6 -2
  91. package/dist/src/mcp/server.js +35 -11
  92. package/dist/src/mcp/server.js.map +1 -1
  93. package/dist/src/media/aiApiClient.d.ts +5 -0
  94. package/dist/src/media/aiApiClient.js +12 -1
  95. package/dist/src/media/aiApiClient.js.map +1 -1
  96. package/dist/src/media/budget.d.ts +42 -0
  97. package/dist/src/media/budget.js +95 -0
  98. package/dist/src/media/budget.js.map +1 -0
  99. package/dist/src/media/executor.d.ts +9 -0
  100. package/dist/src/media/executor.js +1 -1
  101. package/dist/src/media/executor.js.map +1 -1
  102. package/dist/src/media/generateImageTool.d.ts +8 -2
  103. package/dist/src/media/generateImageTool.js +51 -3
  104. package/dist/src/media/generateImageTool.js.map +1 -1
  105. package/dist/src/media/promptSanitizer.d.ts +11 -0
  106. package/dist/src/media/promptSanitizer.js +33 -0
  107. package/dist/src/media/promptSanitizer.js.map +1 -0
  108. package/dist/src/media/taskManager.d.ts +8 -1
  109. package/dist/src/media/taskManager.js +1 -1
  110. package/dist/src/media/taskManager.js.map +1 -1
  111. package/dist/src/tui/App.js +54 -21
  112. package/dist/src/tui/App.js.map +1 -1
  113. package/dist/src/tui/ParamEditor.d.ts +11 -2
  114. package/dist/src/tui/ParamEditor.js +17 -6
  115. package/dist/src/tui/ParamEditor.js.map +1 -1
  116. package/dist/src/tui/atPopup.d.ts +8 -0
  117. package/dist/src/tui/atPopup.js +1 -1
  118. package/dist/src/tui/atPopup.js.map +1 -1
  119. package/dist/src/tui/chatController.d.ts +58 -2
  120. package/dist/src/tui/chatController.js +231 -40
  121. package/dist/src/tui/chatController.js.map +1 -1
  122. package/dist/src/tui/models.d.ts +6 -0
  123. package/dist/src/tui/models.js +14 -6
  124. package/dist/src/tui/models.js.map +1 -1
  125. package/dist/src/tui/run.js +55 -16
  126. package/dist/src/tui/run.js.map +1 -1
  127. package/dist/src/util/brokenPipe.d.ts +10 -0
  128. package/dist/src/util/brokenPipe.js +19 -0
  129. package/dist/src/util/brokenPipe.js.map +1 -0
  130. package/dist/src/web/index.d.ts +8 -0
  131. package/dist/src/web/index.js +10 -0
  132. package/dist/src/web/index.js.map +1 -0
  133. package/dist/src/web/webFetchTool.d.ts +18 -0
  134. package/dist/src/web/webFetchTool.js +222 -0
  135. package/dist/src/web/webFetchTool.js.map +1 -0
  136. package/dist/src/web/webSearchTool.d.ts +22 -0
  137. package/dist/src/web/webSearchTool.js +123 -0
  138. package/dist/src/web/webSearchTool.js.map +1 -0
  139. package/package.json +3 -2
  140. package/skills/globalgpt/SKILL.md +99 -0
  141. package/skills/globalgpt/references/troubleshooting.md +34 -0
  142. package/skills/globalgpt-coding/SKILL.md +65 -0
  143. package/skills/globalgpt-coding/references/troubleshooting.md +34 -0
  144. package/skills/globalgpt-image/SKILL.md +109 -0
  145. package/skills/globalgpt-image/references/recipes.md +52 -0
  146. package/skills/globalgpt-image/references/troubleshooting.md +49 -0
  147. package/skills/globalgpt-video/SKILL.md +109 -0
  148. package/skills/globalgpt-video/references/recipes.md +50 -0
  149. package/skills/globalgpt-video/references/troubleshooting.md +49 -0
@@ -3,6 +3,15 @@ import { type AttachmentContext } from './attachments.js';
3
3
  import { Session } from '../core/session.js';
4
4
  import type { TurnLoop } from '../core/turnLoop.js';
5
5
  import type { TaskEvent } from '../media/taskManager.js';
6
+ /**
7
+ * A styled fragment of one rich-note line. Styles are SEMANTIC (what the text is),
8
+ * not visual — the view maps them to colors in one place, so the whole TUI restyles
9
+ * by editing a single table.
10
+ */
11
+ export interface RichSpan {
12
+ text: string;
13
+ style?: 'header' | 'accent' | 'dim' | 'warn';
14
+ }
6
15
  /** One rendered entry in the conversation timeline. */
7
16
  export type TimelineItem = {
8
17
  kind: 'user';
@@ -20,6 +29,9 @@ export type TimelineItem = {
20
29
  } | {
21
30
  kind: 'note';
22
31
  text: string;
32
+ } | {
33
+ kind: 'rich';
34
+ lines: RichSpan[][];
23
35
  } | {
24
36
  kind: 'media';
25
37
  taskId: string;
@@ -42,6 +54,8 @@ export interface ViewState {
42
54
  queued: QueuedMessage[];
43
55
  /** Cumulative session usage (chat tokens are informational; billing is server-side). */
44
56
  sessionUsage: SessionUsage;
57
+ /** Tool calls dispatched so far THIS turn (reset to 0 at each turn start); drives the footer counter. */
58
+ toolCallsUsed: number;
45
59
  }
46
60
  /** A message resolved at Enter time and waiting for the current turn to finish. */
47
61
  export interface QueuedMessage {
@@ -79,10 +93,21 @@ export interface ControllerExtras {
79
93
  }>>;
80
94
  /** One extra status line for `/status` (e.g. plan/balance from the account API). */
81
95
  fetchAccountLine?: () => Promise<string | null>;
96
+ /** Known newer published version, when the update check has one (run.tsx wires it). */
97
+ updateNotice?: () => {
98
+ latest: string;
99
+ command: string;
100
+ } | null;
101
+ /** Non-default API base URL in effect, if any (run.tsx wires it) — shown by `/status`. */
102
+ baseUrlOverride?: () => string | null;
82
103
  /** Full multi-line account summary for `/account` (email · plan · balance). */
83
104
  fetchAccount?: () => Promise<string>;
84
- /** Recent spend lines for `/transactions` (authoritative billing). */
85
- fetchTransactions?: () => Promise<string>;
105
+ /** Recent spend rows for `/transactions` (authoritative billing). */
106
+ fetchTransactions?: () => Promise<Array<{
107
+ createdAt: string;
108
+ credits: number;
109
+ model: string;
110
+ }>>;
86
111
  /** Re-authenticate in place (`/login`), hot-swapping the token everywhere; returns a status line. */
87
112
  reauth?: (mode: 'browser' | 'no-browser') => Promise<string>;
88
113
  /** Sign out (`/logout`): clear stored credentials; returns a status line. */
@@ -103,6 +128,8 @@ export declare class ChatController {
103
128
  private readonly subscribers;
104
129
  /** Index of the assistant item currently collecting deltas, or -1 if none is open. */
105
130
  private openAssistant;
131
+ /** The upstream-reported prompt size of the LATEST model call — the pruning trigger signal. */
132
+ private lastPromptTokens;
106
133
  /** Abort controller for the in-flight turn (null when idle). */
107
134
  private turnAbort;
108
135
  /** Serializes busy-time enqueues so rapid submits keep keystroke order. */
@@ -130,6 +157,17 @@ export declare class ChatController {
130
157
  * undefined only when `last` was asked for but nothing has been generated yet.
131
158
  */
132
159
  resolveMediaReference(ref: string): string | undefined;
160
+ /**
161
+ * Every media reference this session knows about (generated local paths + hosted URLs), for the
162
+ * generate tools' prompt sanitizer: a reference the model pasted into the prompt TEXT is moved
163
+ * back into the reference parameter. Exact-match source of truth — nothing else is ever touched.
164
+ */
165
+ getKnownMediaRefs(): string[];
166
+ /**
167
+ * Reverse-map a hosted output URL to the local file name the user already knows from the
168
+ * "image ready" line. Display-only: the internal storage hostname must never reach the user.
169
+ */
170
+ localNameForUrl(url: string): string | undefined;
133
171
  /** The session currently being edited (swapped by `/new`); used for persistence. */
134
172
  getSession(): Session;
135
173
  /**
@@ -169,6 +207,15 @@ export declare class ChatController {
169
207
  /** `/resume`: swap in a previously saved session and rebuild the timeline. */
170
208
  replaceSession(session: Session, persistedTimeline?: unknown[]): void;
171
209
  note(text: string): void;
210
+ /** A loud one-line warning (yellow) — for things that must not blend into gray notes. */
211
+ warn(text: string): void;
212
+ private rich;
213
+ /** One `label: value` row — dim label column, optionally styled value. */
214
+ private static kvRow;
215
+ /** Style a `label: value · label: value` summary line without changing its text. */
216
+ private static kvInline;
217
+ /** A value with a trailing parenthetical (e.g. a balance breakdown) renders the detail dim. */
218
+ private static valueSpans;
172
219
  /**
173
220
  * Submit a user message and run one turn to completion, updating the timeline as events
174
221
  * arrive. `displayText` (when given) is what the transcript shows — e.g. "[Image #1]"
@@ -182,6 +229,13 @@ export declare class ChatController {
182
229
  /** Resolve `@…` attachments into a queueable message; null → error already noted. */
183
230
  private resolveForSubmit;
184
231
  /** Run one turn whose attachments are already resolved. Caller owns the busy flag. */
232
+ /**
233
+ * Trigger-based microcompact (spec 2026-07-19-context-pruning-design.md): runs
234
+ * only at turn boundaries, only when the last model call's prompt size crossed
235
+ * the threshold. Batch-clears in one event so the request prefix stays
236
+ * byte-stable between triggers (upstream prompt-cache friendliness).
237
+ */
238
+ private maybePruneContext;
185
239
  private runResolvedTurn;
186
240
  /** Reference-usable images the user attached to the current turn (for auto-reference in generation). */
187
241
  getPendingReferenceImages(): string[];
@@ -199,5 +253,7 @@ export declare class ChatController {
199
253
  private setState;
200
254
  private emit;
201
255
  }
256
+ /** Validate a persisted transcript; returns null when absent/unusable (→ derive instead). */
257
+ export declare function restoreTimeline(persisted: unknown[] | undefined): TimelineItem[] | null;
202
258
  /** Convert a stored message history into renderable timeline items (for resume). */
203
259
  export declare function timelineFromMessages(messages: ChatMessage[]): TimelineItem[];
@@ -1,10 +1,14 @@
1
1
  import { resolveAttachments } from './attachments.js';
2
2
  import { parseSlashMedia } from './slashMedia.js';
3
3
  import { availableModels } from '../media/manifest.js';
4
- import { getChatModels, modelDisplay } from './models.js';
4
+ import { getChatModels, modelDescription, modelSupportsTools } from './models.js';
5
5
  import { SLASH_COMMANDS } from './commands.js';
6
6
  import { Session } from '../core/session.js';
7
7
  import { newId } from '../core/id.js';
8
+ import { selectPrunable } from '../core/contextPrune.js';
9
+ import { MAX_TOOL_CALLS_PER_TURN } from '../fs/index.js';
10
+ import { PRUNE_TRIGGER_TOKENS } from '../fs/limits.js';
11
+ import { getConfigValueSync } from '../config/configFile.js';
8
12
  import path from 'node:path';
9
13
  import os from 'node:os';
10
14
  /** A saved media path is a video when its extension is a known video container. */
@@ -17,15 +21,17 @@ function resolveTokenPath(token) {
17
21
  return path.resolve(expanded);
18
22
  }
19
23
  // Generated from the single command source so /help can never drift from the popup.
20
- const HELP_TEXT = [
21
- 'Commands:',
24
+ const HELP_LINES = [
25
+ [{ text: 'Commands', style: 'header' }],
22
26
  ...SLASH_COMMANDS.map((c) => {
23
- const left = `/${c.name}${c.args ? ` ${c.args}` : ''}`;
24
- return ` ${left.padEnd(24)}${c.description}`;
27
+ const cmd = ` /${c.name}${c.args ? ` ${c.args}` : ''}`;
28
+ // Align descriptions; a command longer than the column still gets breathing room.
29
+ return [{ text: cmd.length >= 26 ? `${cmd} ` : cmd.padEnd(26), style: 'accent' }, { text: c.description }];
25
30
  }),
26
- 'Attach images: @image (last generated) · @clipboard or Ctrl+V (clipboard) · @<path>.',
27
- 'Type anything else to chat. The model replies in your language.',
28
- ].join('\n');
31
+ [],
32
+ [{ text: 'Attach images: @image (last generated) · @clipboard or Ctrl+V (clipboard) · @<path>.', style: 'dim' }],
33
+ [{ text: 'Type anything else to chat. The model replies in your language.', style: 'dim' }],
34
+ ];
29
35
  /**
30
36
  * Headless controller for the chat TUI: owns the view state and maps the engine's
31
37
  * EngineEvent stream into a renderable timeline. Contains no Ink/React so it can be
@@ -39,6 +45,8 @@ export class ChatController {
39
45
  subscribers = new Set();
40
46
  /** Index of the assistant item currently collecting deltas, or -1 if none is open. */
41
47
  openAssistant = -1;
48
+ /** The upstream-reported prompt size of the LATEST model call — the pruning trigger signal. */
49
+ lastPromptTokens = 0;
42
50
  /** Abort controller for the in-flight turn (null when idle). */
43
51
  turnAbort = null;
44
52
  /** Serializes busy-time enqueues so rapid submits keep keystroke order. */
@@ -62,6 +70,7 @@ export class ChatController {
62
70
  runningTasks: [],
63
71
  queued: [],
64
72
  sessionUsage: { promptTokens: 0, completionTokens: 0, turns: 0, mediaCreditsEstimated: 0, mediaSubmits: 0 },
73
+ toolCallsUsed: 0,
65
74
  };
66
75
  }
67
76
  getState() {
@@ -89,6 +98,32 @@ export class ChatController {
89
98
  return r;
90
99
  return this.generatedUrls.get(resolveTokenPath(r)) ?? r;
91
100
  }
101
+ /**
102
+ * Every media reference this session knows about (generated local paths + hosted URLs), for the
103
+ * generate tools' prompt sanitizer: a reference the model pasted into the prompt TEXT is moved
104
+ * back into the reference parameter. Exact-match source of truth — nothing else is ever touched.
105
+ */
106
+ getKnownMediaRefs() {
107
+ const refs = new Set();
108
+ for (const [p, url] of this.generatedUrls) {
109
+ refs.add(p);
110
+ refs.add(url);
111
+ }
112
+ if (this.lastMediaUrl)
113
+ refs.add(this.lastMediaUrl);
114
+ return [...refs];
115
+ }
116
+ /**
117
+ * Reverse-map a hosted output URL to the local file name the user already knows from the
118
+ * "image ready" line. Display-only: the internal storage hostname must never reach the user.
119
+ */
120
+ localNameForUrl(url) {
121
+ for (const [p, u] of this.generatedUrls) {
122
+ if (u === url)
123
+ return path.basename(p);
124
+ }
125
+ return undefined;
126
+ }
92
127
  /** The session currently being edited (swapped by `/new`); used for persistence. */
93
128
  getSession() {
94
129
  return this.session;
@@ -131,10 +166,11 @@ export class ChatController {
131
166
  // A new id so persistence writes a new file instead of overwriting the prior conversation.
132
167
  this.session = new Session({ id: newId(), model: this.session.model });
133
168
  this.openAssistant = -1;
169
+ this.lastPromptTokens = 0; // the pruning trigger must not carry across sessions
134
170
  this.setState({ items: [] });
135
171
  }
136
172
  showHelp() {
137
- this.addItem({ kind: 'note', text: HELP_TEXT });
173
+ this.rich(HELP_LINES);
138
174
  }
139
175
  /** Track background generation tasks: progress updates the summary bar; completion hits the timeline. */
140
176
  handleTaskEvent(e) {
@@ -160,7 +196,11 @@ export class ChatController {
160
196
  // so a follow-up question would truthfully get "I can't see it".
161
197
  this.session.addSystemNote(`[background task update] Generation task ${e.taskId} completed. ` +
162
198
  `Saved locally at ${e.localPath}. Output URL: ${e.outputUrl}.` +
163
- (isVideo ? '' : ' The user can attach it to a message with @image so you can view it.'));
199
+ (isVideo
200
+ ? ''
201
+ : ' The user can attach it to a message with @image so you can view it. To reuse it ' +
202
+ 'as a reference in a NEW generation, pass reference_images: ["last"] (image) or ' +
203
+ 'start_image: "last" (video) — never paste this URL or path into the prompt text.'));
164
204
  }
165
205
  else if (e.type === 'task_failed') {
166
206
  this.state = { ...this.state, runningTasks };
@@ -223,7 +263,11 @@ export class ChatController {
223
263
  async showTasks() {
224
264
  const lines = [];
225
265
  for (const t of this.state.runningTasks) {
226
- lines.push(`⏳ ${t.taskId} · ${t.status} · ${Math.round(t.elapsedMs / 1000)}s`);
266
+ lines.push([
267
+ { text: '⏳ ', style: 'warn' },
268
+ { text: t.taskId, style: 'accent' },
269
+ { text: ` · ${t.status} · ${Math.round(t.elapsedMs / 1000)}s`, style: 'dim' },
270
+ ]);
227
271
  }
228
272
  if (this.extras.listRecentTasks) {
229
273
  try {
@@ -231,27 +275,44 @@ export class ChatController {
231
275
  for (const r of recent.slice(0, 5)) {
232
276
  if (this.state.runningTasks.some((t) => t.taskId === r.taskId))
233
277
  continue;
234
- lines.push(` ${r.taskId} · ${r.model} · ${r.prompt.slice(0, 40)}`);
278
+ lines.push([
279
+ { text: ' ' },
280
+ { text: r.taskId, style: 'accent' },
281
+ { text: ` · ${r.model} · ${r.prompt.slice(0, 40)}`, style: 'dim' },
282
+ ]);
235
283
  }
236
284
  }
237
285
  catch {
238
286
  // Recent history is best-effort.
239
287
  }
240
288
  }
241
- this.note(lines.length ? lines.join('\n') : 'No tasks yet. Try /image or /video.');
289
+ if (lines.length) {
290
+ this.rich([[{ text: 'Tasks', style: 'header' }, { text: ' running and recent', style: 'dim' }], ...lines]);
291
+ }
292
+ else {
293
+ this.note('No tasks yet. Try /image or /video.');
294
+ }
242
295
  }
243
296
  /** `/status`: session/model basics immediately, account line when it arrives. */
244
297
  async showStatus() {
245
- this.note([
246
- `model: ${this.state.model}`,
247
- `conversation: ${this.session.id} (${this.session.messages.length} messages)`,
248
- `running tasks: ${this.state.runningTasks.length}`,
249
- ].join('\n'));
298
+ const lines = [
299
+ ChatController.kvRow('model', this.state.model, 'accent'),
300
+ ChatController.kvRow('conversation', `${this.session.id} (${this.session.messages.length} messages)`),
301
+ ChatController.kvRow('context', `~${this.lastPromptTokens} tokens, ${this.session.prunedToolCallIds.size} old tool results pruned`),
302
+ ChatController.kvRow('running tasks', String(this.state.runningTasks.length)),
303
+ ];
304
+ const override = this.extras.baseUrlOverride?.();
305
+ if (override)
306
+ lines.push(ChatController.kvRow('API base URL', `${override} (override — not the default)`, 'warn'));
307
+ const update = this.extras.updateNotice?.();
308
+ if (update)
309
+ lines.push(ChatController.kvRow('update', `${update.latest} available — ${update.command}`, 'accent'));
310
+ this.rich(lines);
250
311
  if (this.extras.fetchAccountLine) {
251
312
  try {
252
313
  const line = await this.extras.fetchAccountLine();
253
314
  if (line)
254
- this.note(line);
315
+ this.rich([ChatController.kvInline(line)]);
255
316
  }
256
317
  catch {
257
318
  // Account info is best-effort; the basics above are already shown.
@@ -260,17 +321,33 @@ export class ChatController {
260
321
  }
261
322
  /** `/models`: list chat + image/video models available (server-driven catalog applied). */
262
323
  showModels() {
263
- const chat = getChatModels().map((m) => ` ${modelDisplay(m)}`);
264
- const image = availableModels().filter((m) => m.type === 'image').map((m) => ` ${m.id} — ${m.displayName}`);
265
- const video = availableModels().filter((m) => m.type === 'video').map((m) => ` ${m.id} — ${m.displayName}`);
266
- this.note([
267
- `chat (${chat.length}) — /model to switch:`,
268
- ...chat,
269
- `image (${image.length}) — /image <prompt> -m <id>:`,
270
- ...image,
271
- `video (${video.length}) — /video <prompt> -m <id>:`,
272
- ...video,
273
- ].join('\n'));
324
+ const lines = [];
325
+ const section = (title, hint) => {
326
+ if (lines.length > 0)
327
+ lines.push([]);
328
+ lines.push([{ text: title, style: 'header' }, { text: ` ${hint}`, style: 'dim' }]);
329
+ };
330
+ const entry = (id, desc, current) => {
331
+ lines.push([
332
+ { text: current ? ' ▸ ' : ' ', style: 'accent' },
333
+ { text: id, style: 'accent' },
334
+ ...(desc ? [{ text: ` — ${desc}`, style: 'dim' }] : []),
335
+ ...(current ? [{ text: ' (current)', style: 'dim' }] : []),
336
+ ]);
337
+ };
338
+ const chat = getChatModels();
339
+ section(`Chat models (${chat.length})`, '/model to switch');
340
+ for (const m of chat)
341
+ entry(m.id, modelDescription(m), m.id === this.state.model);
342
+ const image = availableModels().filter((m) => m.type === 'image');
343
+ section(`Image models (${image.length})`, '/image <prompt> -m <id>');
344
+ for (const m of image)
345
+ entry(m.id, m.displayName, false);
346
+ const video = availableModels().filter((m) => m.type === 'video');
347
+ section(`Video models (${video.length})`, '/video <prompt> -m <id>');
348
+ for (const m of video)
349
+ entry(m.id, m.displayName, false);
350
+ this.addItem({ kind: 'rich', lines });
274
351
  }
275
352
  /** `/account`: full account summary (email · plan · balance). */
276
353
  async showAccount() {
@@ -279,7 +356,13 @@ export class ChatController {
279
356
  return;
280
357
  }
281
358
  try {
282
- this.note(await this.extras.fetchAccount());
359
+ const text = await this.extras.fetchAccount();
360
+ this.rich(text.split('\n').map((line) => {
361
+ const m = /^([^:]+): (.*)$/.exec(line);
362
+ return m
363
+ ? [{ text: `${m[1]}: `.padEnd(11), style: 'dim' }, ...ChatController.valueSpans(m[2])]
364
+ : [{ text: line }];
365
+ }));
283
366
  }
284
367
  catch (err) {
285
368
  this.note(err instanceof Error ? err.message : String(err));
@@ -292,7 +375,19 @@ export class ChatController {
292
375
  return;
293
376
  }
294
377
  try {
295
- this.note(await this.extras.fetchTransactions());
378
+ const rows = await this.extras.fetchTransactions();
379
+ if (rows.length === 0) {
380
+ this.note('No transactions yet.');
381
+ return;
382
+ }
383
+ this.rich([
384
+ [{ text: 'Recent transactions', style: 'header' }, { text: ' authoritative billing', style: 'dim' }],
385
+ ...rows.map((r) => [
386
+ { text: ` ${(r.createdAt || '').padEnd(20)}`, style: 'dim' },
387
+ { text: `${String(r.credits).padStart(6)} cr`, style: 'accent' },
388
+ { text: ` ${r.model}` },
389
+ ]),
390
+ ]);
296
391
  }
297
392
  catch (err) {
298
393
  this.note(err instanceof Error ? err.message : String(err));
@@ -328,6 +423,7 @@ export class ChatController {
328
423
  replaceSession(session, persistedTimeline) {
329
424
  this.session = session;
330
425
  this.openAssistant = -1;
426
+ this.lastPromptTokens = 0; // the pruning trigger must not carry across sessions
331
427
  this.setState({ model: session.model });
332
428
  this.hydrateFromSession(persistedTimeline);
333
429
  this.note(`Resumed conversation ${session.id}.`);
@@ -335,6 +431,39 @@ export class ChatController {
335
431
  note(text) {
336
432
  this.addItem({ kind: 'note', text });
337
433
  }
434
+ /** A loud one-line warning (yellow) — for things that must not blend into gray notes. */
435
+ warn(text) {
436
+ this.addItem({ kind: 'rich', lines: [[{ text, style: 'warn' }]] });
437
+ }
438
+ rich(lines) {
439
+ this.addItem({ kind: 'rich', lines });
440
+ }
441
+ /** One `label: value` row — dim label column, optionally styled value. */
442
+ static kvRow(label, value, valueStyle) {
443
+ return [
444
+ { text: `${label}: `.padEnd(16), style: 'dim' },
445
+ { text: value, ...(valueStyle ? { style: valueStyle } : {}) },
446
+ ];
447
+ }
448
+ /** Style a `label: value · label: value` summary line without changing its text. */
449
+ static kvInline(text) {
450
+ const spans = [];
451
+ text.split(' · ').forEach((part, i) => {
452
+ if (i > 0)
453
+ spans.push({ text: ' · ', style: 'dim' });
454
+ const m = /^([^:]+): (.*)$/.exec(part);
455
+ if (m)
456
+ spans.push({ text: `${m[1]}: `, style: 'dim' }, ...ChatController.valueSpans(m[2]));
457
+ else
458
+ spans.push({ text: part });
459
+ });
460
+ return spans;
461
+ }
462
+ /** A value with a trailing parenthetical (e.g. a balance breakdown) renders the detail dim. */
463
+ static valueSpans(value) {
464
+ const m = /^(.*?)( \(.+\))$/.exec(value);
465
+ return m ? [{ text: m[1] }, { text: m[2], style: 'dim' }] : [{ text: value }];
466
+ }
338
467
  /**
339
468
  * Submit a user message and run one turn to completion, updating the timeline as events
340
469
  * arrive. `displayText` (when given) is what the transcript shows — e.g. "[Image #1]"
@@ -394,15 +523,48 @@ export class ChatController {
394
523
  return { content, display, refs: attachments.kind === 'parts' ? attachments.refs : undefined };
395
524
  }
396
525
  /** Run one turn whose attachments are already resolved. Caller owns the busy flag. */
526
+ /**
527
+ * Trigger-based microcompact (spec 2026-07-19-context-pruning-design.md): runs
528
+ * only at turn boundaries, only when the last model call's prompt size crossed
529
+ * the threshold. Batch-clears in one event so the request prefix stays
530
+ * byte-stable between triggers (upstream prompt-cache friendliness).
531
+ */
532
+ maybePruneContext() {
533
+ // Cheapest checks first: the in-memory threshold gate skips the sync config
534
+ // read (a disk hit) on every under-threshold turn — the common case.
535
+ if (this.lastPromptTokens <= PRUNE_TRIGGER_TOKENS)
536
+ return;
537
+ if (process.env.GLBGPT_CONTEXT_PRUNING === 'false')
538
+ return;
539
+ if (getConfigValueSync('context-pruning') === 'false')
540
+ return;
541
+ const ids = selectPrunable(this.session.messages, this.session.prunedToolCallIds);
542
+ if (ids.length === 0)
543
+ return;
544
+ const idSet = new Set(ids);
545
+ const clearedBytes = this.session.messages
546
+ .filter((m) => m.role === 'tool' && m.tool_call_id && idSet.has(m.tool_call_id))
547
+ .reduce((sum, m) => sum + (typeof m.content === 'string' ? Buffer.byteLength(m.content, 'utf8') : 0), 0);
548
+ for (const id of ids)
549
+ this.session.prunedToolCallIds.add(id);
550
+ const savedTokens = Math.round(clearedBytes / 4 / 1000);
551
+ this.addItem({
552
+ kind: 'note',
553
+ text: `⚙ Context trimmed: cleared ${ids.length} old tool result${ids.length === 1 ? '' : 's'} (~${savedTokens}k tokens). Re-run tools if that content is needed again.`,
554
+ });
555
+ }
397
556
  async runResolvedTurn(msg) {
398
557
  // A same-message generate_* call auto-uses these when the model gives no explicit reference.
399
558
  this.pendingReferenceImages = msg.refs ?? [];
559
+ this.maybePruneContext();
400
560
  try {
401
561
  this.addItem({ kind: 'user', text: msg.display });
402
562
  this.openAssistant = -1;
403
563
  this.turnAbort = new AbortController();
564
+ this.state = { ...this.state, toolCallsUsed: 0 };
404
565
  await this.turnLoop.runTurn(this.session, msg.content, (event) => this.onEvent(event), {
405
566
  signal: this.turnAbort.signal,
567
+ includeGatedTools: modelSupportsTools(this.session.model),
406
568
  });
407
569
  }
408
570
  catch (err) {
@@ -445,13 +607,13 @@ export class ChatController {
445
607
  /** `/cost`: session usage breakdown. Authoritative spend lives in `glbgpt account transactions`. */
446
608
  showCost() {
447
609
  const u = this.state.sessionUsage;
448
- this.note([
449
- `this session: ${u.turns} turn${u.turns === 1 ? '' : 's'} · ${u.promptTokens.toLocaleString()} in / ${u.completionTokens.toLocaleString()} out tokens`,
450
- u.mediaSubmits > 0
451
- ? `media: ${u.mediaSubmits} task${u.mediaSubmits === 1 ? '' : 's'} submitted · ~${u.mediaCreditsEstimated.toLocaleString()} credits (estimated)`
452
- : 'media: none',
453
- 'authoritative spend: `glbgpt account transactions`',
454
- ].join('\n'));
610
+ this.rich([
611
+ ChatController.kvRow('this session', `${u.turns} turn${u.turns === 1 ? '' : 's'} · ${u.promptTokens.toLocaleString()} in / ${u.completionTokens.toLocaleString()} out tokens`),
612
+ ChatController.kvRow('media', u.mediaSubmits > 0
613
+ ? `${u.mediaSubmits} task${u.mediaSubmits === 1 ? '' : 's'} submitted · ~${u.mediaCreditsEstimated.toLocaleString()} credits (estimated)`
614
+ : 'none'),
615
+ ChatController.kvRow('authoritative spend', 'glbgpt account transactions', 'accent'),
616
+ ]);
455
617
  }
456
618
  /** Interrupt the in-flight turn (Esc). Partial output is kept; no-op when idle. */
457
619
  interrupt() {
@@ -472,6 +634,13 @@ export class ChatController {
472
634
  case 'tool_call_begin': {
473
635
  // Close any open assistant bubble so post-tool text starts a fresh one.
474
636
  this.openAssistant = -1;
637
+ // Past the turn's budget, TurnLoop still emits begin/end for stub calls (it feeds the
638
+ // model a budget-exhausted error instead of dispatching) — clamp so the footer never
639
+ // shows more than the cap it displays (e.g. "18/15").
640
+ this.state = {
641
+ ...this.state,
642
+ toolCallsUsed: Math.min(this.state.toolCallsUsed + 1, MAX_TOOL_CALLS_PER_TURN),
643
+ };
475
644
  this.addItem({ kind: 'tool', id: event.id, name: event.name, status: 'running', args: event.args });
476
645
  break;
477
646
  }
@@ -484,6 +653,7 @@ export class ChatController {
484
653
  break;
485
654
  }
486
655
  case 'usage': {
656
+ this.lastPromptTokens = event.promptTokens;
487
657
  const u = this.state.sessionUsage;
488
658
  this.setState({
489
659
  sessionUsage: {
@@ -546,6 +716,27 @@ function restoreTimelineItem(it) {
546
716
  case 'note':
547
717
  case 'error':
548
718
  return typeof o.text === 'string' ? { kind: o.kind, text: o.text } : null;
719
+ case 'rich': {
720
+ if (!Array.isArray(o.lines))
721
+ return null;
722
+ const lines = [];
723
+ for (const line of o.lines) {
724
+ if (!Array.isArray(line))
725
+ return null;
726
+ const spans = [];
727
+ for (const s of line) {
728
+ if (typeof s !== 'object' || s === null || typeof s.text !== 'string')
729
+ return null;
730
+ const style = s.style;
731
+ spans.push({
732
+ text: s.text,
733
+ ...(style === 'header' || style === 'accent' || style === 'dim' || style === 'warn' ? { style } : {}),
734
+ });
735
+ }
736
+ lines.push(spans);
737
+ }
738
+ return { kind: 'rich', lines };
739
+ }
549
740
  case 'tool':
550
741
  if (typeof o.id !== 'string' || typeof o.name !== 'string')
551
742
  return null;
@@ -573,7 +764,7 @@ function restoreTimelineItem(it) {
573
764
  }
574
765
  }
575
766
  /** Validate a persisted transcript; returns null when absent/unusable (→ derive instead). */
576
- function restoreTimeline(persisted) {
767
+ export function restoreTimeline(persisted) {
577
768
  if (!persisted || persisted.length === 0)
578
769
  return null;
579
770
  const items = persisted.map(restoreTimelineItem).filter((it) => it !== null);