@oh-my-pi/pi-coding-agent 16.2.1 → 16.2.3

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 (187) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/dist/cli.js +3621 -3579
  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/runtime.d.ts +15 -1
  8. package/dist/types/advisor/transcript-recorder.d.ts +13 -2
  9. package/dist/types/advisor/watchdog.d.ts +20 -0
  10. package/dist/types/collab/guest.d.ts +29 -0
  11. package/dist/types/config/model-roles.d.ts +1 -1
  12. package/dist/types/config/settings-schema.d.ts +113 -12
  13. package/dist/types/debug/log-viewer.d.ts +1 -0
  14. package/dist/types/debug/raw-sse.d.ts +1 -0
  15. package/dist/types/discovery/omp-extension-roots.d.ts +3 -3
  16. package/dist/types/edit/hashline/diff.d.ts +0 -11
  17. package/dist/types/edit/index.d.ts +18 -0
  18. package/dist/types/edit/streaming.d.ts +30 -0
  19. package/dist/types/extensibility/custom-tools/types.d.ts +1 -0
  20. package/dist/types/extensibility/shared-events.d.ts +1 -0
  21. package/dist/types/extensibility/tool-event-input.d.ts +7 -0
  22. package/dist/types/extensibility/utils.d.ts +12 -0
  23. package/dist/types/mcp/oauth-discovery.d.ts +0 -11
  24. package/dist/types/mcp/transports/index.d.ts +1 -0
  25. package/dist/types/mcp/transports/sse.d.ts +20 -0
  26. package/dist/types/modes/components/advisor-config.d.ts +59 -0
  27. package/dist/types/modes/components/index.d.ts +1 -0
  28. package/dist/types/modes/components/model-selector.d.ts +9 -1
  29. package/dist/types/modes/components/settings-selector.d.ts +1 -0
  30. package/dist/types/modes/components/status-line/component.d.ts +30 -3
  31. package/dist/types/modes/components/status-line/types.d.ts +13 -1
  32. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  33. package/dist/types/modes/interactive-mode.d.ts +10 -4
  34. package/dist/types/modes/skill-command.d.ts +32 -0
  35. package/dist/types/modes/types.d.ts +7 -2
  36. package/dist/types/sdk.d.ts +1 -1
  37. package/dist/types/session/agent-session.d.ts +84 -12
  38. package/dist/types/session/indexed-session-storage.d.ts +7 -1
  39. package/dist/types/session/messages.d.ts +32 -7
  40. package/dist/types/session/messages.test.d.ts +1 -0
  41. package/dist/types/session/session-entries.d.ts +31 -3
  42. package/dist/types/session/session-history-format.d.ts +6 -0
  43. package/dist/types/session/session-loader.d.ts +9 -1
  44. package/dist/types/session/session-manager.d.ts +6 -4
  45. package/dist/types/session/session-storage.d.ts +11 -0
  46. package/dist/types/session/session-title-slot.d.ts +19 -0
  47. package/dist/types/session/settings-stream-fn.d.ts +21 -0
  48. package/dist/types/session/turn-persistence.d.ts +88 -0
  49. package/dist/types/ssh/connection-manager.d.ts +47 -0
  50. package/dist/types/ssh/utils.d.ts +16 -0
  51. package/dist/types/task/executor.d.ts +3 -16
  52. package/dist/types/task/render.d.ts +0 -5
  53. package/dist/types/task/renderer.d.ts +13 -0
  54. package/dist/types/task/types.d.ts +16 -0
  55. package/dist/types/task/yield-assembly.d.ts +28 -0
  56. package/dist/types/tiny/text.d.ts +8 -0
  57. package/dist/types/tools/render-utils.d.ts +2 -0
  58. package/dist/types/tools/review.d.ts +6 -4
  59. package/dist/types/tools/ssh.d.ts +1 -1
  60. package/dist/types/tools/todo.d.ts +6 -0
  61. package/dist/types/tools/yield.d.ts +8 -3
  62. package/dist/types/utils/thinking-display.d.ts +4 -0
  63. package/package.json +12 -12
  64. package/src/advisor/__tests__/advisor.test.ts +438 -10
  65. package/src/advisor/__tests__/config.test.ts +173 -0
  66. package/src/advisor/advise-tool.ts +11 -6
  67. package/src/advisor/config.ts +256 -0
  68. package/src/advisor/index.ts +1 -0
  69. package/src/advisor/runtime.ts +77 -4
  70. package/src/advisor/transcript-recorder.ts +25 -2
  71. package/src/advisor/watchdog.ts +57 -31
  72. package/src/auto-thinking/classifier.ts +2 -2
  73. package/src/autoresearch/index.ts +7 -2
  74. package/src/cli/gc-cli.ts +17 -10
  75. package/src/collab/guest.ts +43 -7
  76. package/src/config/model-registry.ts +80 -18
  77. package/src/config/model-resolver.ts +5 -1
  78. package/src/config/model-roles.ts +3 -3
  79. package/src/config/settings-schema.ts +107 -8
  80. package/src/debug/index.ts +32 -7
  81. package/src/debug/log-viewer.ts +111 -53
  82. package/src/debug/raw-sse.ts +68 -48
  83. package/src/discovery/codex.ts +13 -5
  84. package/src/discovery/omp-extension-roots.ts +38 -13
  85. package/src/edit/hashline/diff.ts +57 -4
  86. package/src/edit/index.ts +21 -0
  87. package/src/edit/streaming.ts +170 -0
  88. package/src/eval/js/shared/local-module-loader.ts +23 -1
  89. package/src/export/html/template.js +13 -7
  90. package/src/extensibility/custom-tools/types.ts +1 -0
  91. package/src/extensibility/extensions/loader.ts +5 -3
  92. package/src/extensibility/extensions/wrapper.ts +9 -3
  93. package/src/extensibility/hooks/loader.ts +3 -3
  94. package/src/extensibility/hooks/tool-wrapper.ts +13 -4
  95. package/src/extensibility/plugins/legacy-pi-bundled-keys.ts +18 -1
  96. package/src/extensibility/plugins/legacy-pi-bundled-registry.ts +59 -2
  97. package/src/extensibility/plugins/manager.ts +76 -5
  98. package/src/extensibility/shared-events.ts +1 -0
  99. package/src/extensibility/tool-event-input.ts +23 -0
  100. package/src/extensibility/utils.ts +74 -0
  101. package/src/internal-urls/docs-index.generated.txt +1 -1
  102. package/src/mcp/client.ts +3 -1
  103. package/src/mcp/manager.ts +12 -5
  104. package/src/mcp/oauth-discovery.ts +5 -29
  105. package/src/mcp/transports/http.ts +3 -1
  106. package/src/mcp/transports/index.ts +1 -0
  107. package/src/mcp/transports/sse.ts +377 -0
  108. package/src/memories/index.ts +15 -6
  109. package/src/mnemopi/backend.ts +2 -2
  110. package/src/modes/acp/acp-agent.ts +1 -1
  111. package/src/modes/components/advisor-config.ts +555 -0
  112. package/src/modes/components/advisor-message.ts +9 -2
  113. package/src/modes/components/agent-hub.ts +9 -4
  114. package/src/modes/components/assistant-message.ts +5 -5
  115. package/src/modes/components/index.ts +2 -0
  116. package/src/modes/components/model-selector.ts +79 -48
  117. package/src/modes/components/settings-selector.ts +1 -0
  118. package/src/modes/components/status-line/component.ts +145 -14
  119. package/src/modes/components/status-line/segments.ts +47 -22
  120. package/src/modes/components/status-line/types.ts +13 -1
  121. package/src/modes/components/tool-execution.ts +47 -6
  122. package/src/modes/controllers/command-controller.ts +23 -2
  123. package/src/modes/controllers/event-controller.ts +114 -11
  124. package/src/modes/controllers/extension-ui-controller.ts +1 -1
  125. package/src/modes/controllers/input-controller.ts +61 -61
  126. package/src/modes/controllers/selector-controller.ts +100 -9
  127. package/src/modes/interactive-mode.ts +65 -10
  128. package/src/modes/print-mode.ts +1 -1
  129. package/src/modes/skill-command.ts +116 -0
  130. package/src/modes/types.ts +7 -2
  131. package/src/modes/utils/transcript-render-helpers.ts +2 -2
  132. package/src/modes/utils/ui-helpers.ts +46 -27
  133. package/src/prompts/agents/reviewer.md +11 -10
  134. package/src/prompts/review-custom-request.md +1 -2
  135. package/src/prompts/review-request.md +1 -2
  136. package/src/prompts/system/interrupted-thinking.md +7 -0
  137. package/src/prompts/system/recap-user.md +9 -0
  138. package/src/prompts/system/subagent-system-prompt.md +8 -5
  139. package/src/prompts/system/subagent-yield-reminder.md +6 -5
  140. package/src/prompts/system/system-prompt.md +0 -1
  141. package/src/prompts/tools/irc.md +2 -2
  142. package/src/prompts/tools/read.md +2 -2
  143. package/src/sdk.ts +40 -50
  144. package/src/session/agent-session.ts +1139 -600
  145. package/src/session/indexed-session-storage.ts +86 -13
  146. package/src/session/messages.test.ts +125 -0
  147. package/src/session/messages.ts +192 -21
  148. package/src/session/redis-session-storage.ts +49 -2
  149. package/src/session/session-entries.ts +39 -2
  150. package/src/session/session-history-format.ts +29 -2
  151. package/src/session/session-listing.ts +54 -24
  152. package/src/session/session-loader.ts +66 -3
  153. package/src/session/session-manager.ts +113 -19
  154. package/src/session/session-persistence.ts +96 -3
  155. package/src/session/session-storage.ts +36 -0
  156. package/src/session/session-title-slot.ts +141 -0
  157. package/src/session/settings-stream-fn.ts +49 -0
  158. package/src/session/sql-session-storage.ts +71 -11
  159. package/src/session/turn-persistence.ts +142 -0
  160. package/src/session/unexpected-stop-classifier.ts +2 -2
  161. package/src/slash-commands/builtin-registry.ts +16 -3
  162. package/src/slash-commands/helpers/mcp.ts +2 -1
  163. package/src/ssh/__tests__/connection-manager-args.test.ts +123 -1
  164. package/src/ssh/__tests__/file-transfer-posix-guard.test.ts +55 -18
  165. package/src/ssh/connection-manager.ts +139 -12
  166. package/src/ssh/file-transfer.ts +23 -18
  167. package/src/ssh/ssh-executor.ts +2 -13
  168. package/src/ssh/utils.ts +19 -0
  169. package/src/task/executor.ts +21 -23
  170. package/src/task/render.ts +162 -20
  171. package/src/task/renderer.ts +14 -0
  172. package/src/task/types.ts +17 -0
  173. package/src/task/yield-assembly.ts +207 -0
  174. package/src/tiny/models.ts +8 -6
  175. package/src/tiny/text.ts +37 -7
  176. package/src/tools/ask.ts +55 -4
  177. package/src/tools/image-gen.ts +2 -1
  178. package/src/tools/render-utils.ts +2 -0
  179. package/src/tools/renderers.ts +8 -2
  180. package/src/tools/review.ts +17 -7
  181. package/src/tools/ssh.ts +8 -4
  182. package/src/tools/todo.ts +17 -1
  183. package/src/tools/tts.ts +2 -1
  184. package/src/tools/yield.ts +140 -31
  185. package/src/utils/thinking-display.ts +15 -0
  186. package/src/utils/title-generator.ts +1 -1
  187. package/src/web/search/providers/tavily.ts +36 -19
@@ -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,8 +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
- #subagentHubHint: string | undefined;
204
- #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();
205
245
  #planModeStatus: { enabled: boolean; paused: boolean } | null = null;
206
246
  #loopModeStatus: { enabled: boolean } | null = null;
207
247
  #goalModeStatus: { enabled: boolean; paused: boolean } | null = null;
@@ -251,6 +291,7 @@ export class StatusLineComponent implements Component {
251
291
  segmentOptions: settings.getGroup("statusLine").segmentOptions,
252
292
  sessionAccent: settings.get("statusLine.sessionAccent"),
253
293
  transparent: settings.get("statusLine.transparent"),
294
+ compactThinkingLevel: settings.get("statusLine.compactThinkingLevel"),
254
295
  };
255
296
  }
256
297
  #gitEnabled(): boolean {
@@ -284,10 +325,30 @@ export class StatusLineComponent implements Component {
284
325
  if (!sessionChanged && this.#focusedAgentId === focusedAgentId) return;
285
326
  this.session = session;
286
327
  this.#focusedAgentId = focusedAgentId;
287
- if (sessionChanged) this.#invalidateSessionCaches();
328
+ if (sessionChanged) {
329
+ this.#invalidateSessionCaches();
330
+ this.#closeStaleActiveWindow();
331
+ }
288
332
  this.invalidate();
289
333
  }
290
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
+
291
352
  updateSettings(settings: StatusLineSettings): void {
292
353
  this.#settings = settings;
293
354
  this.#effectiveSettings = undefined;
@@ -306,19 +367,89 @@ export class StatusLineComponent implements Component {
306
367
  this.#subagentCount = count;
307
368
  }
308
369
 
309
- /** Hub key label shown in the forced running-subagents badge. */
310
- setSubagentHubHint(hint: string | undefined): void {
311
- const trimmed = hint?.trim();
312
- this.#subagentHubHint = trimmed ? trimmed : undefined;
313
- }
314
-
315
370
  /** Active subagent count as currently displayed (collab state mirroring). */
316
371
  get subagentCount(): number {
317
372
  return this.#subagentCount;
318
373
  }
319
374
 
320
- setSessionStartTime(time: number): void {
321
- this.#sessionStartTime = time;
375
+ /**
376
+ * Reset the currently-attached session's active-time accumulators so
377
+ * the `time_spent` segment starts from zero. Called from `/clear`,
378
+ * fresh-session, and joined-collab paths; both the completed
379
+ * accumulator and any in-flight window are dropped, so a reset
380
+ * mid-turn ignores the running window (the matching `markActivityEnd`
381
+ * will see an idle meter and no-op).
382
+ */
383
+ resetActiveTime(): void {
384
+ const meter = this.#meter();
385
+ meter.activeMs = 0;
386
+ meter.activeStartedAt = null;
387
+ }
388
+
389
+ /**
390
+ * Mark the currently-attached session as having started a unit of
391
+ * active processing. Idempotent: a second start while a window is
392
+ * already open is a no-op, so reentrant `agent_start` events (e.g.
393
+ * nested auto-compaction loops, focus-controller mid-turn attach onto
394
+ * an already-running window) do not double-count.
395
+ */
396
+ markActivityStart(): void {
397
+ const meter = this.#meter();
398
+ if (meter.activeStartedAt !== null) return;
399
+ meter.activeStartedAt = Date.now();
400
+ }
401
+
402
+ /**
403
+ * Close the currently-attached session's open active-processing
404
+ * window, folding its elapsed time into the accumulator. Idempotent
405
+ * when the meter is already idle so callers can fire it on every
406
+ * `agent_end` without guarding.
407
+ */
408
+ markActivityEnd(): void {
409
+ const meter = this.#meter();
410
+ if (meter.activeStartedAt === null) return;
411
+ meter.activeMs += Math.max(0, Date.now() - meter.activeStartedAt);
412
+ meter.activeStartedAt = null;
413
+ }
414
+
415
+ /**
416
+ * Snapshot of total active-processing time for the currently-attached
417
+ * session, including any in-flight window. Exposed for the segment
418
+ * context builder; tests assert against this too.
419
+ */
420
+ getActiveMs(): number {
421
+ const meter = this.#meter();
422
+ if (meter.activeStartedAt === null) return meter.activeMs;
423
+ return meter.activeMs + Math.max(0, Date.now() - meter.activeStartedAt);
424
+ }
425
+
426
+ /**
427
+ * Return (lazily creating) the meter for the currently-attached
428
+ * session. Detects an in-place session-file swap under the same
429
+ * {@link AgentSession} ref (`switchSession` paths: `/resume`, `/move`,
430
+ * ACP fork/load, RPC `switch_session`, extension `switchSession`):
431
+ * a real-to-real change starts the meter fresh so the new
432
+ * conversation does not inherit the previous one's accumulated active
433
+ * time. The undefined → real first-save transition only refreshes the
434
+ * snapshot — the conversation identity has not changed.
435
+ */
436
+ #meter(): ActiveMeter {
437
+ const currentFile = this.session.sessionFile;
438
+ let meter = this.#activeMeters.get(this.session);
439
+ if (meter) {
440
+ const switched =
441
+ currentFile !== undefined && meter.sessionFile !== undefined && meter.sessionFile !== currentFile;
442
+ if (switched) {
443
+ meter = undefined;
444
+ } else {
445
+ meter.sessionFile = currentFile;
446
+ }
447
+ }
448
+ if (!meter) {
449
+ meter = { activeMs: 0, activeStartedAt: null, sessionFile: currentFile };
450
+ this.#activeMeters.set(this.session, meter);
451
+ }
452
+ return meter;
322
453
  }
323
454
 
324
455
  setPlanModeStatus(status: { enabled: boolean; paused: boolean } | undefined): void {
@@ -861,6 +992,7 @@ export class StatusLineComponent implements Component {
861
992
  activeRepo: activeRepoCache.activeRepo,
862
993
  width,
863
994
  options: segmentOptions ?? {},
995
+ compactThinkingLevel: this.#resolveSettings().compactThinkingLevel ?? false,
864
996
  planMode: this.#planModeStatus,
865
997
  loopMode: this.#loopModeStatus,
866
998
  goalMode: this.#goalModeStatus,
@@ -871,7 +1003,7 @@ export class StatusLineComponent implements Component {
871
1003
  contextWindow,
872
1004
  autoCompactEnabled: this.#autoCompactEnabled,
873
1005
  subagentCount: this.#subagentCount,
874
- sessionStartTime: this.#sessionStartTime,
1006
+ activeMs: this.getActiveMs(),
875
1007
  git: {
876
1008
  branch: gitBranch,
877
1009
  status: gitStatus,
@@ -925,8 +1057,7 @@ export class StatusLineComponent implements Component {
925
1057
  #subagentBadgeText(): string | undefined {
926
1058
  if (this.#subagentCount === 0) return undefined;
927
1059
  const noun = this.#subagentCount === 1 ? "agent" : "agents";
928
- const hubHint = this.#subagentHubHint ? ` — ${this.#subagentHubHint} hub` : " — Agent Hub";
929
- return theme.fg("statusLineSubagents", `${theme.icon.agents} ${this.#subagentCount} ${noun} running${hubHint}`);
1060
+ return theme.fg("statusLineSubagents", `${theme.icon.agents} ${this.#subagentCount} ${noun}`);
930
1061
  }
931
1062
 
932
1063
  #buildStatusLine(width: number): string {
@@ -20,8 +20,18 @@ 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
- for (const root of ["/work", path.join(os.homedir(), "Projects")]) {
34
+ for (const root of [path.join(os.homedir(), "Projects"), "/work"]) {
25
35
  const relative = relativePathWithinRoot(root, pwd);
26
36
  if (relative) return relative;
27
37
  }
@@ -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;