@oh-my-pi/pi-coding-agent 16.2.2 → 16.2.4

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 (164) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/dist/cli.js +3682 -3615
  3. package/dist/types/advisor/__tests__/config.test.d.ts +1 -0
  4. package/dist/types/advisor/advise-tool.d.ts +8 -4
  5. package/dist/types/advisor/config.d.ts +88 -0
  6. package/dist/types/advisor/index.d.ts +1 -0
  7. package/dist/types/advisor/transcript-recorder.d.ts +13 -2
  8. package/dist/types/advisor/watchdog.d.ts +20 -0
  9. package/dist/types/cli/update-cli.d.ts +15 -0
  10. package/dist/types/collab/guest.d.ts +29 -0
  11. package/dist/types/collab/replication-shrink.d.ts +39 -0
  12. package/dist/types/config/provider-globals.d.ts +7 -0
  13. package/dist/types/config/settings-schema.d.ts +81 -0
  14. package/dist/types/debug/log-viewer.d.ts +1 -0
  15. package/dist/types/debug/raw-sse.d.ts +1 -0
  16. package/dist/types/edit/hashline/diff.d.ts +0 -11
  17. package/dist/types/extensibility/tool-event-input.d.ts +7 -0
  18. package/dist/types/extensibility/utils.d.ts +12 -0
  19. package/dist/types/mcp/transports/index.d.ts +1 -0
  20. package/dist/types/mcp/transports/sse.d.ts +20 -0
  21. package/dist/types/memories/index.d.ts +20 -1
  22. package/dist/types/modes/components/advisor-config.d.ts +59 -0
  23. package/dist/types/modes/components/index.d.ts +1 -0
  24. package/dist/types/modes/components/model-selector.d.ts +9 -1
  25. package/dist/types/modes/components/settings-selector.d.ts +1 -0
  26. package/dist/types/modes/components/status-line/component.d.ts +35 -1
  27. package/dist/types/modes/components/status-line/types.d.ts +13 -1
  28. package/dist/types/modes/controllers/command-controller.d.ts +3 -3
  29. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  30. package/dist/types/modes/interactive-mode.d.ts +11 -4
  31. package/dist/types/modes/skill-command.d.ts +32 -0
  32. package/dist/types/modes/types.d.ts +9 -3
  33. package/dist/types/session/agent-session.d.ts +58 -10
  34. package/dist/types/session/indexed-session-storage.d.ts +7 -1
  35. package/dist/types/session/messages.d.ts +26 -0
  36. package/dist/types/session/messages.test.d.ts +1 -0
  37. package/dist/types/session/session-entries.d.ts +31 -3
  38. package/dist/types/session/session-history-format.d.ts +6 -0
  39. package/dist/types/session/session-loader.d.ts +9 -1
  40. package/dist/types/session/session-manager.d.ts +8 -7
  41. package/dist/types/session/session-storage.d.ts +11 -0
  42. package/dist/types/session/session-title-slot.d.ts +19 -0
  43. package/dist/types/ssh/connection-manager.d.ts +47 -0
  44. package/dist/types/ssh/utils.d.ts +16 -0
  45. package/dist/types/task/executor.d.ts +3 -16
  46. package/dist/types/task/render.d.ts +0 -5
  47. package/dist/types/task/renderer.d.ts +13 -0
  48. package/dist/types/task/types.d.ts +16 -0
  49. package/dist/types/task/yield-assembly.d.ts +28 -0
  50. package/dist/types/tiny/text.d.ts +8 -0
  51. package/dist/types/tools/render-utils.d.ts +2 -0
  52. package/dist/types/tools/review.d.ts +6 -4
  53. package/dist/types/tools/ssh.d.ts +1 -1
  54. package/dist/types/tools/todo.d.ts +6 -0
  55. package/dist/types/tools/yield.d.ts +8 -3
  56. package/dist/types/utils/thinking-display.d.ts +4 -0
  57. package/package.json +12 -12
  58. package/src/advisor/__tests__/advisor.test.ts +242 -10
  59. package/src/advisor/__tests__/config.test.ts +173 -0
  60. package/src/advisor/advise-tool.ts +11 -6
  61. package/src/advisor/config.ts +256 -0
  62. package/src/advisor/index.ts +1 -0
  63. package/src/advisor/runtime.ts +12 -2
  64. package/src/advisor/transcript-recorder.ts +25 -2
  65. package/src/advisor/watchdog.ts +57 -31
  66. package/src/autolearn/controller.ts +13 -22
  67. package/src/autoresearch/index.ts +7 -2
  68. package/src/cli/gc-cli.ts +17 -10
  69. package/src/cli/update-cli.ts +254 -0
  70. package/src/collab/guest.ts +43 -7
  71. package/src/collab/host.ts +13 -8
  72. package/src/collab/replication-shrink.ts +111 -0
  73. package/src/config/model-registry.ts +80 -18
  74. package/src/config/provider-globals.ts +25 -0
  75. package/src/config/settings-schema.ts +77 -0
  76. package/src/debug/index.ts +32 -7
  77. package/src/debug/log-viewer.ts +111 -53
  78. package/src/debug/raw-sse.ts +68 -48
  79. package/src/discovery/codex.ts +13 -5
  80. package/src/edit/hashline/diff.ts +57 -4
  81. package/src/eval/__tests__/julia-prelude.test.ts +2 -2
  82. package/src/eval/js/shared/local-module-loader.ts +23 -1
  83. package/src/export/html/template.js +13 -7
  84. package/src/extensibility/extensions/loader.ts +5 -3
  85. package/src/extensibility/extensions/wrapper.ts +9 -3
  86. package/src/extensibility/hooks/loader.ts +3 -3
  87. package/src/extensibility/hooks/tool-wrapper.ts +13 -4
  88. package/src/extensibility/plugins/manager.ts +2 -1
  89. package/src/extensibility/tool-event-input.ts +23 -0
  90. package/src/extensibility/utils.ts +74 -0
  91. package/src/internal-urls/docs-index.generated.txt +1 -1
  92. package/src/mcp/client.ts +3 -1
  93. package/src/mcp/manager.ts +12 -5
  94. package/src/mcp/transports/index.ts +1 -0
  95. package/src/mcp/transports/sse.ts +377 -0
  96. package/src/memories/index.ts +130 -15
  97. package/src/memory-backend/local-backend.ts +5 -3
  98. package/src/modes/components/advisor-config.ts +555 -0
  99. package/src/modes/components/advisor-message.ts +9 -2
  100. package/src/modes/components/agent-hub.ts +9 -4
  101. package/src/modes/components/index.ts +2 -0
  102. package/src/modes/components/model-selector.ts +79 -48
  103. package/src/modes/components/settings-selector.ts +1 -0
  104. package/src/modes/components/status-line/component.ts +150 -5
  105. package/src/modes/components/status-line/segments.ts +46 -21
  106. package/src/modes/components/status-line/types.ts +13 -1
  107. package/src/modes/components/tool-execution.ts +47 -6
  108. package/src/modes/controllers/command-controller.ts +27 -36
  109. package/src/modes/controllers/event-controller.ts +113 -1
  110. package/src/modes/controllers/extension-ui-controller.ts +1 -1
  111. package/src/modes/controllers/input-controller.ts +61 -61
  112. package/src/modes/controllers/selector-controller.ts +100 -9
  113. package/src/modes/interactive-mode.ts +74 -25
  114. package/src/modes/skill-command.ts +116 -0
  115. package/src/modes/types.ts +9 -3
  116. package/src/modes/utils/ui-helpers.ts +41 -23
  117. package/src/prompts/agents/reviewer.md +11 -10
  118. package/src/prompts/goals/goal-todo-context.md +12 -0
  119. package/src/prompts/review-custom-request.md +1 -2
  120. package/src/prompts/review-request.md +1 -2
  121. package/src/prompts/system/interrupted-thinking.md +7 -0
  122. package/src/prompts/system/recap-user.md +9 -0
  123. package/src/prompts/system/subagent-system-prompt.md +8 -5
  124. package/src/prompts/system/subagent-yield-reminder.md +6 -5
  125. package/src/prompts/system/system-prompt.md +0 -1
  126. package/src/prompts/tools/irc.md +2 -2
  127. package/src/prompts/tools/read.md +2 -2
  128. package/src/sdk.ts +28 -24
  129. package/src/session/agent-session.ts +899 -429
  130. package/src/session/indexed-session-storage.ts +86 -13
  131. package/src/session/messages.test.ts +125 -0
  132. package/src/session/messages.ts +172 -9
  133. package/src/session/redis-session-storage.ts +49 -2
  134. package/src/session/session-entries.ts +39 -2
  135. package/src/session/session-history-format.ts +29 -2
  136. package/src/session/session-listing.ts +54 -24
  137. package/src/session/session-loader.ts +66 -3
  138. package/src/session/session-manager.ts +115 -22
  139. package/src/session/session-persistence.ts +95 -1
  140. package/src/session/session-storage.ts +36 -0
  141. package/src/session/session-title-slot.ts +141 -0
  142. package/src/session/sql-session-storage.ts +71 -11
  143. package/src/slash-commands/builtin-registry.ts +23 -24
  144. package/src/ssh/__tests__/connection-manager-args.test.ts +123 -1
  145. package/src/ssh/__tests__/file-transfer-posix-guard.test.ts +55 -18
  146. package/src/ssh/connection-manager.ts +139 -12
  147. package/src/ssh/file-transfer.ts +23 -18
  148. package/src/ssh/ssh-executor.ts +2 -13
  149. package/src/ssh/utils.ts +19 -0
  150. package/src/task/executor.ts +21 -23
  151. package/src/task/render.ts +162 -20
  152. package/src/task/renderer.ts +14 -0
  153. package/src/task/types.ts +17 -0
  154. package/src/task/yield-assembly.ts +207 -0
  155. package/src/tiny/text.ts +23 -0
  156. package/src/tools/ask.ts +55 -4
  157. package/src/tools/render-utils.ts +2 -0
  158. package/src/tools/renderers.ts +8 -2
  159. package/src/tools/review.ts +17 -7
  160. package/src/tools/ssh.ts +8 -4
  161. package/src/tools/todo.ts +17 -1
  162. package/src/tools/yield.ts +140 -31
  163. package/src/utils/thinking-display.ts +15 -0
  164. package/src/prompts/system/autolearn-nudge.md +0 -5
@@ -9,6 +9,7 @@ import {
9
9
  Input,
10
10
  matchesKey,
11
11
  ScrollView,
12
+ type SgrMouseEvent,
12
13
  Spacer,
13
14
  type Tab,
14
15
  TabBar,
@@ -174,7 +175,12 @@ export class ModelSelectorComponent extends Container {
174
175
  #tui: TUI;
175
176
  #scopedModels: ReadonlyArray<ScopedModelItem>;
176
177
  #temporaryOnly: boolean;
178
+ #directSelect: boolean;
179
+ #pickerHint: string | undefined;
177
180
  #currentContextTokens: number;
181
+ #listLineOffset = 0;
182
+ #listStartIndex = 0;
183
+ #listVisibleCount = 0;
178
184
 
179
185
  #menuRoleActions: MenuRoleAction[] = [];
180
186
 
@@ -200,7 +206,13 @@ export class ModelSelectorComponent extends Container {
200
206
  scopedModels: ReadonlyArray<ScopedModelItem>,
201
207
  onSelect: RoleSelectCallback,
202
208
  onCancel: () => void,
203
- options?: { temporaryOnly?: boolean; initialSearchInput?: string; currentContextTokens?: number },
209
+ options?: {
210
+ temporaryOnly?: boolean;
211
+ directSelect?: boolean;
212
+ pickerHint?: string;
213
+ initialSearchInput?: string;
214
+ currentContextTokens?: number;
215
+ },
204
216
  ) {
205
217
  super();
206
218
 
@@ -211,6 +223,8 @@ export class ModelSelectorComponent extends Container {
211
223
  this.#onSelectCallback = onSelect;
212
224
  this.#onCancelCallback = onCancel;
213
225
  this.#temporaryOnly = options?.temporaryOnly ?? false;
226
+ this.#directSelect = options?.directSelect ?? false;
227
+ this.#pickerHint = options?.pickerHint;
214
228
  const currentContextTokens = options?.currentContextTokens ?? 0;
215
229
  this.#currentContextTokens =
216
230
  Number.isFinite(currentContextTokens) && currentContextTokens > 0 ? Math.floor(currentContextTokens) : 0;
@@ -236,6 +250,9 @@ export class ModelSelectorComponent extends Container {
236
250
  if (this.#temporaryOnly) {
237
251
  this.addChild(new Text(theme.fg("muted", TEMPORARY_MODEL_PICKER_HINT), 0, 0));
238
252
  this.addChild(new Spacer(1));
253
+ } else if (this.#directSelect && this.#pickerHint) {
254
+ this.addChild(new Text(theme.fg("muted", this.#pickerHint), 0, 0));
255
+ this.addChild(new Spacer(1));
239
256
  }
240
257
 
241
258
  // Create header container for tab bar
@@ -714,10 +731,6 @@ export class ModelSelectorComponent extends Container {
714
731
  return this.#temporaryOnly && this.#isModelOverCurrentContext(model);
715
732
  }
716
733
 
717
- #isDefaultRoleActionOverContextLimit(action: MenuRoleAction, model: Model): boolean {
718
- return action.role === "default" && this.#isModelOverCurrentContext(model);
719
- }
720
-
721
734
  #formatCurrentContextLimitSuffix(model: Model): string {
722
735
  return ` ${theme.status.disabled} context>${formatNumber(model.contextWindow ?? 0).toLowerCase()}`;
723
736
  }
@@ -937,6 +950,8 @@ export class ModelSelectorComponent extends Container {
937
950
  Math.min(this.#selectedIndex - Math.floor(maxVisible / 2), visibleItems.length - maxVisible),
938
951
  );
939
952
  const endIndex = Math.min(startIndex + maxVisible, visibleItems.length);
953
+ this.#listStartIndex = startIndex;
954
+ this.#listVisibleCount = Math.max(0, endIndex - startIndex);
940
955
 
941
956
  const showProvider = this.#getActiveTabId() === ALL_TAB;
942
957
 
@@ -1065,47 +1080,16 @@ export class ModelSelectorComponent extends Container {
1065
1080
  : this.#filteredModels[this.#selectedIndex];
1066
1081
  }
1067
1082
 
1068
- #isMenuRoleIndexDisabled(index: number, selectedItem: ModelItem | CanonicalModelItem): boolean {
1069
- const action = this.#menuRoleActions[index];
1070
- return action ? this.#isDefaultRoleActionOverContextLimit(action, selectedItem.model) : false;
1071
- }
1072
-
1073
- #coerceMenuSelectedIndex(index: number, selectedItem: ModelItem | CanonicalModelItem): number {
1083
+ #coerceMenuSelectedIndex(index: number): number {
1074
1084
  const maxIndex = this.#menuRoleActions.length - 1;
1075
1085
  if (maxIndex < 0) {
1076
1086
  return 0;
1077
1087
  }
1078
- const clamped = Math.max(0, Math.min(index, maxIndex));
1079
- if (!this.#isMenuRoleIndexDisabled(clamped, selectedItem)) {
1080
- return clamped;
1081
- }
1082
- for (let i = clamped + 1; i <= maxIndex; i++) {
1083
- if (!this.#isMenuRoleIndexDisabled(i, selectedItem)) {
1084
- return i;
1085
- }
1086
- }
1087
- for (let i = clamped - 1; i >= 0; i--) {
1088
- if (!this.#isMenuRoleIndexDisabled(i, selectedItem)) {
1089
- return i;
1090
- }
1091
- }
1092
- return clamped;
1088
+ return Math.max(0, Math.min(index, maxIndex));
1093
1089
  }
1094
1090
 
1095
- #moveMenuSelection(delta: number, selectedItem: ModelItem | CanonicalModelItem, optionCount: number): void {
1096
- let index = this.#menuSelectedIndex;
1097
- for (let step = 0; step < optionCount; step++) {
1098
- index = (index + delta + optionCount) % optionCount;
1099
- if (this.#menuStep !== "role" || !this.#isMenuRoleIndexDisabled(index, selectedItem)) {
1100
- this.#menuSelectedIndex = index;
1101
- this.#updateMenu();
1102
- return;
1103
- }
1104
- }
1105
- this.#menuSelectedIndex =
1106
- this.#menuStep === "role"
1107
- ? this.#coerceMenuSelectedIndex(this.#menuSelectedIndex, selectedItem)
1108
- : this.#menuSelectedIndex;
1091
+ #moveMenuSelection(delta: number, _selectedItem: ModelItem | CanonicalModelItem, optionCount: number): void {
1092
+ this.#menuSelectedIndex = (this.#menuSelectedIndex + delta + optionCount) % optionCount;
1109
1093
  this.#updateMenu();
1110
1094
  }
1111
1095
 
@@ -1116,7 +1100,7 @@ export class ModelSelectorComponent extends Container {
1116
1100
  this.#isMenuOpen = true;
1117
1101
  this.#menuStep = "role";
1118
1102
  this.#menuSelectedRole = null;
1119
- this.#menuSelectedIndex = this.#coerceMenuSelectedIndex(0, selectedItem);
1103
+ this.#menuSelectedIndex = this.#coerceMenuSelectedIndex(0);
1120
1104
  // Collapse the model list while the action/thinking menu is open so the
1121
1105
  // menu owns the full viewport instead of stacking below a now-irrelevant
1122
1106
  // (and often off-screen) list.
@@ -1149,9 +1133,7 @@ export class ModelSelectorComponent extends Container {
1149
1133
  })
1150
1134
  : this.#menuRoleActions.map((action, index) => {
1151
1135
  const prefix = index === this.#menuSelectedIndex ? ` ${theme.nav.cursor} ` : " ";
1152
- const disabled = this.#isDefaultRoleActionOverContextLimit(action, selectedItem.model);
1153
- const suffix = disabled ? this.#formatCurrentContextLimitSuffix(selectedItem.model) : "";
1154
- return `${prefix}${action.label}${suffix}`;
1136
+ return `${prefix}${action.label}`;
1155
1137
  });
1156
1138
 
1157
1139
  const selectedRoleName = this.#menuSelectedRole ? getRoleInfo(this.#menuSelectedRole, this.#settings).name : "";
@@ -1231,6 +1213,55 @@ export class ModelSelectorComponent extends Container {
1231
1213
  return Math.max(MIN_VISIBLE_OPTIONS, Math.min(optionCount, terminalRows - MENU_CHROME_ROWS));
1232
1214
  }
1233
1215
 
1216
+ /**
1217
+ * Concatenate children like Container.render, recording where the model list
1218
+ * lands so routed mouse events can be hit-tested against it.
1219
+ */
1220
+ override render(width: number): readonly string[] {
1221
+ const lines: string[] = [];
1222
+ for (const child of this.children) {
1223
+ const childLines = child.render(Math.max(1, width));
1224
+ if (child === this.#listContainer) {
1225
+ this.#listLineOffset = lines.length;
1226
+ }
1227
+ lines.push(...childLines);
1228
+ }
1229
+ return lines;
1230
+ }
1231
+
1232
+ routeMouse(event: SgrMouseEvent, line: number, _col: number): void {
1233
+ if (this.#isMenuOpen) return;
1234
+
1235
+ if (event.wheel !== null) {
1236
+ this.#moveSelection(event.wheel);
1237
+ return;
1238
+ }
1239
+
1240
+ const listLine = line - this.#listLineOffset;
1241
+ if (listLine < 0 || listLine >= this.#listVisibleCount) return;
1242
+
1243
+ const index = this.#listStartIndex + listLine;
1244
+ const item = this.#getVisibleItems()[index];
1245
+ if (!item || this.#isItemDisabled(item)) return;
1246
+
1247
+ if (event.motion) {
1248
+ if (index !== this.#selectedIndex) {
1249
+ this.#selectedIndex = index;
1250
+ this.#updateList();
1251
+ }
1252
+ return;
1253
+ }
1254
+
1255
+ if (event.leftClick) {
1256
+ this.#selectedIndex = index;
1257
+ if (this.#temporaryOnly || this.#directSelect) {
1258
+ this.#handleSelect(item, null);
1259
+ } else {
1260
+ this.#openMenu();
1261
+ }
1262
+ }
1263
+ }
1264
+
1234
1265
  handleInput(keyData: string): void {
1235
1266
  if (this.#isMenuOpen) {
1236
1267
  this.#handleMenuInput(keyData);
@@ -1254,12 +1285,12 @@ export class ModelSelectorComponent extends Container {
1254
1285
  return;
1255
1286
  }
1256
1287
 
1257
- // Enter - open context menu or select directly in temporary mode
1288
+ // Enter - open context menu or select directly in temporary/direct-select mode
1258
1289
  if (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") {
1259
1290
  const selectedItem = this.#getSelectedItem();
1260
1291
  if (selectedItem && !this.#isItemDisabled(selectedItem)) {
1261
- if (this.#temporaryOnly) {
1262
- // In temporary mode, skip menu and select directly
1292
+ if (this.#temporaryOnly || this.#directSelect) {
1293
+ // In temporary/direct-select mode, skip menu and select directly
1263
1294
  this.#handleSelect(selectedItem, null);
1264
1295
  } else {
1265
1296
  this.#openMenu();
@@ -1301,7 +1332,7 @@ export class ModelSelectorComponent extends Container {
1301
1332
  if (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") {
1302
1333
  if (this.#menuStep === "role") {
1303
1334
  const action = this.#menuRoleActions[this.#menuSelectedIndex];
1304
- if (!action || this.#isDefaultRoleActionOverContextLimit(action, selectedItem.model)) return;
1335
+ if (!action) return;
1305
1336
  this.#menuSelectedRole = action.role;
1306
1337
  this.#menuStep = "thinking";
1307
1338
  this.#menuSelectedIndex = this.#getThinkingPreselectIndex(action.role, selectedItem.model);
@@ -387,6 +387,7 @@ export interface StatusLinePreviewSettings {
387
387
  separator?: StatusLineSeparatorStyle;
388
388
  sessionAccent?: boolean;
389
389
  transparent?: boolean;
390
+ compactThinkingLevel?: boolean;
390
391
  }
391
392
 
392
393
  export interface SettingsCallbacks {
@@ -162,6 +162,29 @@ interface ActiveRepoCache {
162
162
  effectiveGitCwd: string;
163
163
  }
164
164
 
165
+ /**
166
+ * Per-{@link AgentSession} active-processing meter for the `time_spent`
167
+ * segment. `activeMs` is the union of every completed `agent_start`→
168
+ * `agent_end` window; `activeStartedAt` is the start timestamp of the
169
+ * currently-running window, or `null` when idle.
170
+ *
171
+ * `sessionFile` snapshots the loaded session-file path at meter-creation
172
+ * time. `AgentSession.switchSession` (/resume, /move, ACP fork, RPC
173
+ * `switch_session`, extension `switchSession`) mutates the loaded file
174
+ * under the same {@link AgentSession} ref, so the WeakMap key alone
175
+ * cannot tell two conversations apart. `#meter()` compares this snapshot
176
+ * against the live `session.sessionFile`, and a real-to-real change
177
+ * starts the meter fresh instead of crediting the new conversation with
178
+ * the previous one's accumulated active time. The undefined → real
179
+ * first-save transition does not reset, since the session identity has
180
+ * not changed.
181
+ */
182
+ interface ActiveMeter {
183
+ activeMs: number;
184
+ activeStartedAt: number | null;
185
+ sessionFile: string | undefined;
186
+ }
187
+
165
188
  const EMPTY_MESSAGES: readonly AgentMessage[] = [];
166
189
  const STATUS_USAGE_START_DELAY_MS = 0;
167
190
  const STATUS_USAGE_REFRESH_TIMEOUT_MS = 2_000;
@@ -200,7 +223,25 @@ export class StatusLineComponent implements Component {
200
223
  #autoCompactEnabled: boolean = true;
201
224
  #hookStatuses: Map<string, string> = new Map();
202
225
  #subagentCount: number = 0;
203
- #sessionStartTime: number = Date.now();
226
+ /**
227
+ * Active-processing accounting for the `time_spent` segment, keyed per
228
+ * {@link AgentSession} so the focus-controller mid-turn attach path
229
+ * cannot leak an unmatched synthesized `agent_start` from a subagent
230
+ * into the main session's meter.
231
+ *
232
+ * Each meter is `{ activeMs, activeStartedAt }`: `activeMs` is the union
233
+ * of every completed `agent_start`→`agent_end` window since
234
+ * {@link resetActiveTime} last reset it; `activeStartedAt` is the start
235
+ * timestamp of the currently-running window (or `null` when idle).
236
+ * `getActiveMs()` returns `activeMs + (now - activeStartedAt)` for the
237
+ * currently-attached session, so the counter ticks live during a turn
238
+ * and freezes the instant the agent yields.
239
+ *
240
+ * WeakMap so meters die with their session (e.g. a parked subagent
241
+ * dropped from the registry); the main session's meter survives focus
242
+ * round-trips because the same {@link AgentSession} ref is reused.
243
+ */
244
+ #activeMeters: WeakMap<AgentSession, ActiveMeter> = new WeakMap();
204
245
  #planModeStatus: { enabled: boolean; paused: boolean } | null = null;
205
246
  #loopModeStatus: { enabled: boolean } | null = null;
206
247
  #goalModeStatus: { enabled: boolean; paused: boolean } | null = null;
@@ -250,6 +291,7 @@ export class StatusLineComponent implements Component {
250
291
  segmentOptions: settings.getGroup("statusLine").segmentOptions,
251
292
  sessionAccent: settings.get("statusLine.sessionAccent"),
252
293
  transparent: settings.get("statusLine.transparent"),
294
+ compactThinkingLevel: settings.get("statusLine.compactThinkingLevel"),
253
295
  };
254
296
  }
255
297
  #gitEnabled(): boolean {
@@ -283,10 +325,30 @@ export class StatusLineComponent implements Component {
283
325
  if (!sessionChanged && this.#focusedAgentId === focusedAgentId) return;
284
326
  this.session = session;
285
327
  this.#focusedAgentId = focusedAgentId;
286
- if (sessionChanged) this.#invalidateSessionCaches();
328
+ if (sessionChanged) {
329
+ this.#invalidateSessionCaches();
330
+ this.#closeStaleActiveWindow();
331
+ }
287
332
  this.invalidate();
288
333
  }
289
334
 
335
+ /**
336
+ * Drop a meter's in-flight window when the newly-attached session is no
337
+ * longer streaming. Handles the case where the focus controller
338
+ * synthesized an `agent_start` on a mid-turn attach but the matching
339
+ * real `agent_end` never reached us — the user detached before it
340
+ * fired, and re-focusing later (after the agent finished) would
341
+ * otherwise tick over the entire detached gap. Crediting that gap to
342
+ * `activeMs` would be wrong (the agent finished at some point we never
343
+ * observed), so the window is dropped rather than folded in.
344
+ */
345
+ #closeStaleActiveWindow(): void {
346
+ const meter = this.#meter();
347
+ if (meter.activeStartedAt === null) return;
348
+ if (this.session.isStreaming) return;
349
+ meter.activeStartedAt = null;
350
+ }
351
+
290
352
  updateSettings(settings: StatusLineSettings): void {
291
353
  this.#settings = settings;
292
354
  this.#effectiveSettings = undefined;
@@ -305,13 +367,95 @@ export class StatusLineComponent implements Component {
305
367
  this.#subagentCount = count;
306
368
  }
307
369
 
370
+ /**
371
+ * Compatibility shim for callers predating the simplified subagent badge.
372
+ * The status line now intentionally shows only the active count.
373
+ */
374
+ setSubagentHubHint(_hint: string | undefined): void {}
375
+
308
376
  /** Active subagent count as currently displayed (collab state mirroring). */
309
377
  get subagentCount(): number {
310
378
  return this.#subagentCount;
311
379
  }
312
380
 
313
- setSessionStartTime(time: number): void {
314
- this.#sessionStartTime = time;
381
+ /**
382
+ * Reset the currently-attached session's active-time accumulators so
383
+ * the `time_spent` segment starts from zero. Called from `/clear`,
384
+ * fresh-session, and joined-collab paths; both the completed
385
+ * accumulator and any in-flight window are dropped, so a reset
386
+ * mid-turn ignores the running window (the matching `markActivityEnd`
387
+ * will see an idle meter and no-op).
388
+ */
389
+ resetActiveTime(): void {
390
+ const meter = this.#meter();
391
+ meter.activeMs = 0;
392
+ meter.activeStartedAt = null;
393
+ }
394
+
395
+ /**
396
+ * Mark the currently-attached session as having started a unit of
397
+ * active processing. Idempotent: a second start while a window is
398
+ * already open is a no-op, so reentrant `agent_start` events (e.g.
399
+ * nested auto-compaction loops, focus-controller mid-turn attach onto
400
+ * an already-running window) do not double-count.
401
+ */
402
+ markActivityStart(): void {
403
+ const meter = this.#meter();
404
+ if (meter.activeStartedAt !== null) return;
405
+ meter.activeStartedAt = Date.now();
406
+ }
407
+
408
+ /**
409
+ * Close the currently-attached session's open active-processing
410
+ * window, folding its elapsed time into the accumulator. Idempotent
411
+ * when the meter is already idle so callers can fire it on every
412
+ * `agent_end` without guarding.
413
+ */
414
+ markActivityEnd(): void {
415
+ const meter = this.#meter();
416
+ if (meter.activeStartedAt === null) return;
417
+ meter.activeMs += Math.max(0, Date.now() - meter.activeStartedAt);
418
+ meter.activeStartedAt = null;
419
+ }
420
+
421
+ /**
422
+ * Snapshot of total active-processing time for the currently-attached
423
+ * session, including any in-flight window. Exposed for the segment
424
+ * context builder; tests assert against this too.
425
+ */
426
+ getActiveMs(): number {
427
+ const meter = this.#meter();
428
+ if (meter.activeStartedAt === null) return meter.activeMs;
429
+ return meter.activeMs + Math.max(0, Date.now() - meter.activeStartedAt);
430
+ }
431
+
432
+ /**
433
+ * Return (lazily creating) the meter for the currently-attached
434
+ * session. Detects an in-place session-file swap under the same
435
+ * {@link AgentSession} ref (`switchSession` paths: `/resume`, `/move`,
436
+ * ACP fork/load, RPC `switch_session`, extension `switchSession`):
437
+ * a real-to-real change starts the meter fresh so the new
438
+ * conversation does not inherit the previous one's accumulated active
439
+ * time. The undefined → real first-save transition only refreshes the
440
+ * snapshot — the conversation identity has not changed.
441
+ */
442
+ #meter(): ActiveMeter {
443
+ const currentFile = this.session.sessionFile;
444
+ let meter = this.#activeMeters.get(this.session);
445
+ if (meter) {
446
+ const switched =
447
+ currentFile !== undefined && meter.sessionFile !== undefined && meter.sessionFile !== currentFile;
448
+ if (switched) {
449
+ meter = undefined;
450
+ } else {
451
+ meter.sessionFile = currentFile;
452
+ }
453
+ }
454
+ if (!meter) {
455
+ meter = { activeMs: 0, activeStartedAt: null, sessionFile: currentFile };
456
+ this.#activeMeters.set(this.session, meter);
457
+ }
458
+ return meter;
315
459
  }
316
460
 
317
461
  setPlanModeStatus(status: { enabled: boolean; paused: boolean } | undefined): void {
@@ -854,6 +998,7 @@ export class StatusLineComponent implements Component {
854
998
  activeRepo: activeRepoCache.activeRepo,
855
999
  width,
856
1000
  options: segmentOptions ?? {},
1001
+ compactThinkingLevel: this.#resolveSettings().compactThinkingLevel ?? false,
857
1002
  planMode: this.#planModeStatus,
858
1003
  loopMode: this.#loopModeStatus,
859
1004
  goalMode: this.#goalModeStatus,
@@ -864,7 +1009,7 @@ export class StatusLineComponent implements Component {
864
1009
  contextWindow,
865
1010
  autoCompactEnabled: this.#autoCompactEnabled,
866
1011
  subagentCount: this.#subagentCount,
867
- sessionStartTime: this.#sessionStartTime,
1012
+ activeMs: this.getActiveMs(),
868
1013
  git: {
869
1014
  branch: gitBranch,
870
1015
  status: gitStatus,
@@ -20,6 +20,16 @@ function withIcon(icon: string, text: string): string {
20
20
  return icon ? `${icon} ${text}` : text;
21
21
  }
22
22
 
23
+ /**
24
+ * Leading glyph of a thinking-level display string (e.g. "◉ xhigh" → "◉").
25
+ * Compact mode promotes this glyph to the model-segment icon so the level
26
+ * stays visible without the verbose " · <level>" tail.
27
+ */
28
+ function thinkingGlyph(display: string): string {
29
+ const space = display.indexOf(" ");
30
+ return space === -1 ? display : display.slice(0, space);
31
+ }
32
+
23
33
  function stripDisplayRoot(pwd: string): string {
24
34
  for (const root of [path.join(os.homedir(), "Projects"), "/work"]) {
25
35
  const relative = relativePathWithinRoot(root, pwd);
@@ -86,37 +96,46 @@ const modelSegment: StatusLineSegment = {
86
96
  modelName = modelName.slice(7);
87
97
  }
88
98
 
89
- // Fast-mode icon and thinking-level suffix trail the model name and are
90
- // colored together with it as `statusLineModel`. The advisor "++" badge
91
- // sits between the name and that tail in `accent`, so it reads as a
92
- // distinct marker. theme.fg resets only the fg, so the spans are
93
- // concatenated (not nested) to keep each color intact.
94
- let tail = "";
95
- if (ctx.session.isFastModeActive() && theme.icon.fast) {
96
- tail += ` ${theme.icon.fast}`;
97
- }
98
-
99
+ // Resolve the current thinking-level display ("◉ xhigh", "⟳ auto", …)
100
+ // when the model supports thinking and the segment isn't hiding it.
101
+ let thinkingDisplay = "";
99
102
  if (opts.showThinkingLevel !== false && state.model?.thinking) {
100
103
  if (ctx.session.isAutoThinking) {
101
104
  // Pending (no turn classified yet / classifying) shows a symbol-theme
102
105
  // question-box marker; once resolved it shows `<level>`.
103
106
  const resolved = ctx.session.autoResolvedThinkingLevel();
104
- const resolvedText = resolved ? (theme.thinking[resolved as keyof typeof theme.thinking] ?? resolved) : "";
105
- tail += `${theme.sep.dot}${resolved ? resolvedText : `${theme.thinking.autoPending} auto`}`;
107
+ thinkingDisplay = resolved
108
+ ? (theme.thinking[resolved as keyof typeof theme.thinking] ?? resolved)
109
+ : `${theme.thinking.autoPending} auto`;
106
110
  } else {
107
111
  const level = state.thinkingLevel ?? ThinkingLevel.Off;
108
112
  if (level !== ThinkingLevel.Off) {
109
- const thinkingText = theme.thinking[level as keyof typeof theme.thinking];
110
- if (thinkingText) {
111
- tail += `${theme.sep.dot}${thinkingText}`;
112
- }
113
+ thinkingDisplay = theme.thinking[level as keyof typeof theme.thinking] ?? "";
113
114
  }
114
115
  }
115
116
  }
116
117
 
118
+ // Compact mode swaps the model icon for the thinking-level glyph and drops
119
+ // the " · <level>" tail, keeping the level visible as a single icon.
120
+ const compact = ctx.compactThinkingLevel && thinkingDisplay !== "";
121
+ const modelIcon = compact ? thinkingGlyph(thinkingDisplay) : theme.icon.model;
122
+
123
+ // Fast-mode icon and thinking-level suffix trail the model name and are
124
+ // colored together with it as `statusLineModel`. The advisor "++" badge
125
+ // sits between the name and that tail in `accent`, so it reads as a
126
+ // distinct marker. theme.fg resets only the fg, so the spans are
127
+ // concatenated (not nested) to keep each color intact.
128
+ let tail = "";
129
+ if (ctx.session.isFastModeActive() && theme.icon.fast) {
130
+ tail += ` ${theme.icon.fast}`;
131
+ }
132
+ if (!compact && thinkingDisplay) {
133
+ tail += `${theme.sep.dot}${thinkingDisplay}`;
134
+ }
135
+
117
136
  // `statusLineModel` is aliased to `accent` in many themes, so the badge
118
137
  // uses `success` to stay visibly distinct from the model name color.
119
- let content = theme.fg("statusLineModel", withIcon(theme.icon.model, modelName));
138
+ let content = theme.fg("statusLineModel", withIcon(modelIcon, modelName));
120
139
  if (ctx.session.isAdvisorActive()) {
121
140
  content += theme.fg("success", "++");
122
141
  }
@@ -400,13 +419,19 @@ const contextTotalSegment: StatusLineSegment = {
400
419
  },
401
420
  };
402
421
 
422
+ /**
423
+ * Total time the agent was actively processing this session — the union of
424
+ * every `agent_start`→`agent_end` window plus the currently-running window,
425
+ * sourced from {@link SegmentContext.activeMs}. Idle wall-clock between turns
426
+ * never accumulates, so the displayed total reflects how long the agent has
427
+ * been working for the user, not how long the session has been open. Hidden
428
+ * before the first second of activity to avoid flashing `0s` at session start.
429
+ */
403
430
  const timeSpentSegment: StatusLineSegment = {
404
431
  id: "time_spent",
405
432
  render(ctx) {
406
- const elapsed = Date.now() - ctx.sessionStartTime;
407
- if (elapsed < 1000) return { content: "", visible: false };
408
-
409
- return { content: withIcon(theme.icon.time, formatDuration(elapsed)), visible: true };
433
+ if (ctx.activeMs < 1000) return { content: "", visible: false };
434
+ return { content: withIcon(theme.icon.time, formatDuration(ctx.activeMs)), visible: true };
410
435
  },
411
436
  };
412
437
 
@@ -31,6 +31,9 @@ export interface StatusLineSettings {
31
31
  /** Drop the theme's `statusLineBg` fill and powerline caps so the bar
32
32
  * inherits the terminal's default background. */
33
33
  transparent?: boolean;
34
+ /** Replace the model-segment icon with the thinking-level glyph and drop the
35
+ * " · <level>" suffix, so the thinking level reads as a single compact icon. */
36
+ compactThinkingLevel?: boolean;
34
37
  }
35
38
 
36
39
  export type EffectiveStatusLineSettings = Required<
@@ -51,6 +54,8 @@ export interface SegmentContext {
51
54
  activeRepo: ActiveRepoContext | null;
52
55
  width: number;
53
56
  options: StatusLineSegmentOptions;
57
+ /** Render the model segment's thinking level as a compact leading glyph. */
58
+ compactThinkingLevel: boolean;
54
59
  planMode: {
55
60
  enabled: boolean;
56
61
  paused: boolean;
@@ -79,7 +84,14 @@ export interface SegmentContext {
79
84
  contextWindow: number;
80
85
  autoCompactEnabled: boolean;
81
86
  subagentCount: number;
82
- sessionStartTime: number;
87
+ /**
88
+ * Active processing time accumulated this session, in ms — the union of
89
+ * every `agent_start`→`agent_end` window plus the currently-streaming
90
+ * window if the agent is running. Idle wall-clock never contributes, so
91
+ * this is what {@link StatusLineSegmentId.time_spent} renders instead of
92
+ * `Date.now() - sessionStart`.
93
+ */
94
+ activeMs: number;
83
95
  git: {
84
96
  branch: string | null;
85
97
  status: { staged: number; unstaged: number; untracked: number } | null;