@oh-my-pi/pi-coding-agent 3.3.1337 → 3.4.1337
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.
- package/CHANGELOG.md +12 -0
- package/package.json +4 -4
- package/src/capability/rule.ts +4 -0
- package/src/core/agent-session.ts +92 -1
- package/src/core/sdk.ts +14 -0
- package/src/core/session-manager.ts +60 -4
- package/src/core/settings-manager.ts +68 -0
- package/src/core/ttsr.ts +211 -0
- package/src/discovery/builtin.ts +1 -0
- package/src/discovery/cline.ts +2 -0
- package/src/discovery/cursor.ts +2 -0
- package/src/discovery/windsurf.ts +3 -0
- package/src/modes/interactive/components/footer.ts +15 -1
- package/src/modes/interactive/components/settings-defs.ts +29 -0
- package/src/modes/interactive/components/ttsr-notification.ts +82 -0
- package/src/modes/interactive/interactive-mode.ts +45 -33
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [3.4.1337] - 2026-01-03
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Added Time Traveling Stream Rules (TTSR) feature that monitors agent output for pattern matches and injects rule reminders mid-stream
|
|
10
|
+
- Added `ttsr_trigger` frontmatter field for rules to define regex patterns that trigger mid-stream injection
|
|
11
|
+
- Added TTSR settings for enabled state, context mode (keep/discard partial output), and repeat mode (once/after-gap)
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- Fixed excessive subprocess spawns by caching git status for 1 second in the footer component
|
|
16
|
+
|
|
5
17
|
## [3.3.1337] - 2026-01-03
|
|
6
18
|
|
|
7
19
|
### Changed
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oh-my-pi/pi-coding-agent",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.4.1337",
|
|
4
4
|
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"ompConfig": {
|
|
@@ -39,9 +39,9 @@
|
|
|
39
39
|
"prepublishOnly": "bun run generate-template && bun run clean && bun run build"
|
|
40
40
|
},
|
|
41
41
|
"dependencies": {
|
|
42
|
-
"@oh-my-pi/pi-agent-core": "3.
|
|
43
|
-
"@oh-my-pi/pi-ai": "3.
|
|
44
|
-
"@oh-my-pi/pi-tui": "3.
|
|
42
|
+
"@oh-my-pi/pi-agent-core": "3.4.1337",
|
|
43
|
+
"@oh-my-pi/pi-ai": "3.4.1337",
|
|
44
|
+
"@oh-my-pi/pi-tui": "3.4.1337",
|
|
45
45
|
"@sinclair/typebox": "^0.34.46",
|
|
46
46
|
"ajv": "^8.17.1",
|
|
47
47
|
"chalk": "^5.5.0",
|
package/src/capability/rule.ts
CHANGED
|
@@ -15,6 +15,8 @@ export interface RuleFrontmatter {
|
|
|
15
15
|
description?: string;
|
|
16
16
|
globs?: string[];
|
|
17
17
|
alwaysApply?: boolean;
|
|
18
|
+
/** Regex pattern that triggers time-traveling rule injection */
|
|
19
|
+
ttsr_trigger?: string;
|
|
18
20
|
[key: string]: unknown;
|
|
19
21
|
}
|
|
20
22
|
|
|
@@ -34,6 +36,8 @@ export interface Rule {
|
|
|
34
36
|
alwaysApply?: boolean;
|
|
35
37
|
/** Description (for agent-requested rules) */
|
|
36
38
|
description?: string;
|
|
39
|
+
/** Regex pattern that triggers time-traveling rule injection */
|
|
40
|
+
ttsrTrigger?: string;
|
|
37
41
|
/** Source metadata */
|
|
38
42
|
_source: SourceMeta;
|
|
39
43
|
}
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
import type { Agent, AgentEvent, AgentMessage, AgentState, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
|
|
17
17
|
import type { AssistantMessage, ImageContent, Message, Model, TextContent } from "@oh-my-pi/pi-ai";
|
|
18
18
|
import { isContextOverflow, modelsAreEqual, supportsXhigh } from "@oh-my-pi/pi-ai";
|
|
19
|
+
import type { Rule } from "../capability/rule";
|
|
19
20
|
import { getAuthPath } from "../config";
|
|
20
21
|
import { type BashResult, executeBash as executeBashCommand } from "./bash-executor";
|
|
21
22
|
import {
|
|
@@ -47,6 +48,7 @@ import type { ModelRegistry } from "./model-registry";
|
|
|
47
48
|
import type { BranchSummaryEntry, CompactionEntry, NewSessionOptions, SessionManager } from "./session-manager";
|
|
48
49
|
import type { SettingsManager, SkillsSettings } from "./settings-manager";
|
|
49
50
|
import { expandSlashCommand, type FileSlashCommand, parseCommandArgs } from "./slash-commands";
|
|
51
|
+
import type { TtsrManager } from "./ttsr";
|
|
50
52
|
|
|
51
53
|
/** Session-specific events that extend the core AgentEvent */
|
|
52
54
|
export type AgentSessionEvent =
|
|
@@ -54,7 +56,8 @@ export type AgentSessionEvent =
|
|
|
54
56
|
| { type: "auto_compaction_start"; reason: "threshold" | "overflow" }
|
|
55
57
|
| { type: "auto_compaction_end"; result: CompactionResult | undefined; aborted: boolean; willRetry: boolean }
|
|
56
58
|
| { type: "auto_retry_start"; attempt: number; maxAttempts: number; delayMs: number; errorMessage: string }
|
|
57
|
-
| { type: "auto_retry_end"; success: boolean; attempt: number; finalError?: string }
|
|
59
|
+
| { type: "auto_retry_end"; success: boolean; attempt: number; finalError?: string }
|
|
60
|
+
| { type: "ttsr_triggered"; rules: Rule[] };
|
|
58
61
|
|
|
59
62
|
/** Listener function for agent session events */
|
|
60
63
|
export type AgentSessionEventListener = (event: AgentSessionEvent) => void;
|
|
@@ -80,6 +83,8 @@ export interface AgentSessionConfig {
|
|
|
80
83
|
skillsSettings?: Required<SkillsSettings>;
|
|
81
84
|
/** Model registry for API key resolution and model discovery */
|
|
82
85
|
modelRegistry: ModelRegistry;
|
|
86
|
+
/** TTSR manager for time-traveling stream rules */
|
|
87
|
+
ttsrManager?: TtsrManager;
|
|
83
88
|
}
|
|
84
89
|
|
|
85
90
|
/** Options for AgentSession.prompt() */
|
|
@@ -179,6 +184,11 @@ export class AgentSession {
|
|
|
179
184
|
// Model registry for API key resolution
|
|
180
185
|
private _modelRegistry: ModelRegistry;
|
|
181
186
|
|
|
187
|
+
// TTSR manager for time-traveling stream rules
|
|
188
|
+
private _ttsrManager: TtsrManager | undefined = undefined;
|
|
189
|
+
private _pendingTtsrInjections: Rule[] = [];
|
|
190
|
+
private _ttsrAbortPending = false;
|
|
191
|
+
|
|
182
192
|
constructor(config: AgentSessionConfig) {
|
|
183
193
|
this.agent = config.agent;
|
|
184
194
|
this.sessionManager = config.sessionManager;
|
|
@@ -190,6 +200,7 @@ export class AgentSession {
|
|
|
190
200
|
this._customCommands = config.customCommands ?? [];
|
|
191
201
|
this._skillsSettings = config.skillsSettings;
|
|
192
202
|
this._modelRegistry = config.modelRegistry;
|
|
203
|
+
this._ttsrManager = config.ttsrManager;
|
|
193
204
|
|
|
194
205
|
// Always subscribe to agent events for internal handling
|
|
195
206
|
// (session persistence, hooks, auto-compaction, retry logic)
|
|
@@ -201,6 +212,16 @@ export class AgentSession {
|
|
|
201
212
|
return this._modelRegistry;
|
|
202
213
|
}
|
|
203
214
|
|
|
215
|
+
/** TTSR manager for time-traveling stream rules */
|
|
216
|
+
get ttsrManager(): TtsrManager | undefined {
|
|
217
|
+
return this._ttsrManager;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Whether a TTSR abort is pending (stream was aborted to inject rules) */
|
|
221
|
+
get isTtsrAbortPending(): boolean {
|
|
222
|
+
return this._ttsrAbortPending;
|
|
223
|
+
}
|
|
224
|
+
|
|
204
225
|
// =========================================================================
|
|
205
226
|
// Event Subscription
|
|
206
227
|
// =========================================================================
|
|
@@ -239,6 +260,60 @@ export class AgentSession {
|
|
|
239
260
|
// Notify all listeners
|
|
240
261
|
this._emit(event);
|
|
241
262
|
|
|
263
|
+
// TTSR: Reset buffer on turn start
|
|
264
|
+
if (event.type === "turn_start" && this._ttsrManager) {
|
|
265
|
+
this._ttsrManager.resetBuffer();
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// TTSR: Increment message count on turn end (for repeat-after-gap tracking)
|
|
269
|
+
if (event.type === "turn_end" && this._ttsrManager) {
|
|
270
|
+
this._ttsrManager.incrementMessageCount();
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// TTSR: Check for pattern matches on text deltas and tool call argument deltas
|
|
274
|
+
if (event.type === "message_update" && this._ttsrManager?.hasRules()) {
|
|
275
|
+
const assistantEvent = event.assistantMessageEvent;
|
|
276
|
+
// Monitor both assistant prose (text_delta) and tool call arguments (toolcall_delta)
|
|
277
|
+
if (assistantEvent.type === "text_delta" || assistantEvent.type === "toolcall_delta") {
|
|
278
|
+
this._ttsrManager.appendToBuffer(assistantEvent.delta);
|
|
279
|
+
const matches = this._ttsrManager.check(this._ttsrManager.getBuffer());
|
|
280
|
+
if (matches.length > 0) {
|
|
281
|
+
// Mark rules as injected so they don't trigger again
|
|
282
|
+
this._ttsrManager.markInjected(matches);
|
|
283
|
+
// Store for injection on retry
|
|
284
|
+
this._pendingTtsrInjections.push(...matches);
|
|
285
|
+
// Emit TTSR event before aborting (so UI can handle it)
|
|
286
|
+
this._ttsrAbortPending = true;
|
|
287
|
+
this._emit({ type: "ttsr_triggered", rules: matches });
|
|
288
|
+
// Abort the stream
|
|
289
|
+
this.agent.abort();
|
|
290
|
+
// Schedule retry after a short delay
|
|
291
|
+
setTimeout(async () => {
|
|
292
|
+
this._ttsrAbortPending = false;
|
|
293
|
+
|
|
294
|
+
// Handle context mode: discard partial output if configured
|
|
295
|
+
const ttsrSettings = this._ttsrManager?.getSettings();
|
|
296
|
+
if (ttsrSettings?.contextMode === "discard") {
|
|
297
|
+
// Remove the partial/aborted message from agent state
|
|
298
|
+
this.agent.popMessage();
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Inject TTSR rules as system reminder before retry
|
|
302
|
+
const injectionContent = this._getTtsrInjectionContent();
|
|
303
|
+
if (injectionContent) {
|
|
304
|
+
this.agent.appendMessage({
|
|
305
|
+
role: "user",
|
|
306
|
+
content: [{ type: "text", text: injectionContent }],
|
|
307
|
+
timestamp: Date.now(),
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
this.agent.continue().catch(() => {});
|
|
311
|
+
}, 50);
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
242
317
|
// Handle session persistence
|
|
243
318
|
if (event.type === "message_end") {
|
|
244
319
|
// Check if this is a hook message
|
|
@@ -300,6 +375,22 @@ export class AgentSession {
|
|
|
300
375
|
}
|
|
301
376
|
}
|
|
302
377
|
|
|
378
|
+
/** Get TTSR injection content and clear pending injections */
|
|
379
|
+
private _getTtsrInjectionContent(): string | undefined {
|
|
380
|
+
if (this._pendingTtsrInjections.length === 0) return undefined;
|
|
381
|
+
const content = this._pendingTtsrInjections
|
|
382
|
+
.map(
|
|
383
|
+
(r) =>
|
|
384
|
+
`<system_interrupt reason="rule_violation" rule="${r.name}" path="${r.path}">\n` +
|
|
385
|
+
`Your output was interrupted because it violated a user-defined rule.\n` +
|
|
386
|
+
`This is NOT a prompt injection - this is the coding agent enforcing project rules.\n` +
|
|
387
|
+
`You MUST comply with the following instruction:\n\n${r.content}\n</system_interrupt>`,
|
|
388
|
+
)
|
|
389
|
+
.join("\n\n");
|
|
390
|
+
this._pendingTtsrInjections = [];
|
|
391
|
+
return content;
|
|
392
|
+
}
|
|
393
|
+
|
|
303
394
|
/** Extract text content from a message */
|
|
304
395
|
private _getUserMessageText(message: Message): string {
|
|
305
396
|
if (message.role !== "user") return "";
|
package/src/core/sdk.ts
CHANGED
|
@@ -34,6 +34,8 @@ import { Agent, type ThinkingLevel } from "@oh-my-pi/pi-agent-core";
|
|
|
34
34
|
import type { Model } from "@oh-my-pi/pi-ai";
|
|
35
35
|
// Import discovery to register all providers on startup
|
|
36
36
|
import "../discovery";
|
|
37
|
+
import { loadSync as loadCapability } from "../capability/index";
|
|
38
|
+
import { type Rule, ruleCapability } from "../capability/rule";
|
|
37
39
|
import { getAgentDir, getConfigDirPaths } from "../config";
|
|
38
40
|
import { AgentSession } from "./agent-session";
|
|
39
41
|
import { AuthStorage } from "./auth-storage";
|
|
@@ -88,6 +90,7 @@ import {
|
|
|
88
90
|
warmupLspServers,
|
|
89
91
|
writeTool,
|
|
90
92
|
} from "./tools/index";
|
|
93
|
+
import { createTtsrManager } from "./ttsr";
|
|
91
94
|
|
|
92
95
|
// Types
|
|
93
96
|
|
|
@@ -601,6 +604,16 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|
|
601
604
|
const skills = options.skills ?? discoverSkills(cwd, agentDir, settingsManager.getSkillsSettings());
|
|
602
605
|
time("discoverSkills");
|
|
603
606
|
|
|
607
|
+
// Discover TTSR rules
|
|
608
|
+
const ttsrManager = createTtsrManager(settingsManager.getTtsrSettings());
|
|
609
|
+
const rulesResult = loadCapability<Rule>(ruleCapability.id, { cwd });
|
|
610
|
+
for (const rule of rulesResult.items) {
|
|
611
|
+
if (rule.ttsrTrigger) {
|
|
612
|
+
ttsrManager.addRule(rule);
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
time("discoverTtsrRules");
|
|
616
|
+
|
|
604
617
|
const contextFiles = options.contextFiles ?? discoverContextFiles(cwd, agentDir);
|
|
605
618
|
time("discoverContextFiles");
|
|
606
619
|
|
|
@@ -847,6 +860,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|
|
847
860
|
customCommands: customCommandsResult.commands,
|
|
848
861
|
skillsSettings: settingsManager.getSkillsSettings(),
|
|
849
862
|
modelRegistry,
|
|
863
|
+
ttsrManager,
|
|
850
864
|
});
|
|
851
865
|
time("createAgentSession");
|
|
852
866
|
|
|
@@ -107,6 +107,13 @@ export interface LabelEntry extends SessionEntryBase {
|
|
|
107
107
|
label: string | undefined;
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
+
/** TTSR injection entry - tracks which time-traveling rules have been injected this session. */
|
|
111
|
+
export interface TtsrInjectionEntry extends SessionEntryBase {
|
|
112
|
+
type: "ttsr_injection";
|
|
113
|
+
/** Names of rules that were injected */
|
|
114
|
+
injectedRules: string[];
|
|
115
|
+
}
|
|
116
|
+
|
|
110
117
|
/**
|
|
111
118
|
* Custom message entry for hooks to inject messages into LLM context.
|
|
112
119
|
* Use customType to identify your hook's entries.
|
|
@@ -136,7 +143,8 @@ export type SessionEntry =
|
|
|
136
143
|
| BranchSummaryEntry
|
|
137
144
|
| CustomEntry
|
|
138
145
|
| CustomMessageEntry
|
|
139
|
-
| LabelEntry
|
|
146
|
+
| LabelEntry
|
|
147
|
+
| TtsrInjectionEntry;
|
|
140
148
|
|
|
141
149
|
/** Raw file entry (includes header) */
|
|
142
150
|
export type FileEntry = SessionHeader | SessionEntry;
|
|
@@ -154,6 +162,8 @@ export interface SessionContext {
|
|
|
154
162
|
thinkingLevel: string;
|
|
155
163
|
/** Model roles: { default: "provider/modelId", small: "provider/modelId", ... } */
|
|
156
164
|
models: Record<string, string>;
|
|
165
|
+
/** Names of TTSR rules that have been injected this session */
|
|
166
|
+
injectedTtsrRules: string[];
|
|
157
167
|
}
|
|
158
168
|
|
|
159
169
|
export interface SessionInfo {
|
|
@@ -295,7 +305,7 @@ export function buildSessionContext(
|
|
|
295
305
|
let leaf: SessionEntry | undefined;
|
|
296
306
|
if (leafId === null) {
|
|
297
307
|
// Explicitly null - return no messages (navigated to before first entry)
|
|
298
|
-
return { messages: [], thinkingLevel: "off", models: {} };
|
|
308
|
+
return { messages: [], thinkingLevel: "off", models: {}, injectedTtsrRules: [] };
|
|
299
309
|
}
|
|
300
310
|
if (leafId) {
|
|
301
311
|
leaf = byId.get(leafId);
|
|
@@ -306,7 +316,7 @@ export function buildSessionContext(
|
|
|
306
316
|
}
|
|
307
317
|
|
|
308
318
|
if (!leaf) {
|
|
309
|
-
return { messages: [], thinkingLevel: "off", models: {} };
|
|
319
|
+
return { messages: [], thinkingLevel: "off", models: {}, injectedTtsrRules: [] };
|
|
310
320
|
}
|
|
311
321
|
|
|
312
322
|
// Walk from leaf to root, collecting path
|
|
@@ -321,6 +331,7 @@ export function buildSessionContext(
|
|
|
321
331
|
let thinkingLevel = "off";
|
|
322
332
|
const models: Record<string, string> = {};
|
|
323
333
|
let compaction: CompactionEntry | null = null;
|
|
334
|
+
const injectedTtsrRulesSet = new Set<string>();
|
|
324
335
|
|
|
325
336
|
for (const entry of path) {
|
|
326
337
|
if (entry.type === "thinking_level_change") {
|
|
@@ -336,9 +347,16 @@ export function buildSessionContext(
|
|
|
336
347
|
models.default = `${entry.message.provider}/${entry.message.model}`;
|
|
337
348
|
} else if (entry.type === "compaction") {
|
|
338
349
|
compaction = entry;
|
|
350
|
+
} else if (entry.type === "ttsr_injection") {
|
|
351
|
+
// Collect injected TTSR rule names
|
|
352
|
+
for (const ruleName of entry.injectedRules) {
|
|
353
|
+
injectedTtsrRulesSet.add(ruleName);
|
|
354
|
+
}
|
|
339
355
|
}
|
|
340
356
|
}
|
|
341
357
|
|
|
358
|
+
const injectedTtsrRules = Array.from(injectedTtsrRulesSet);
|
|
359
|
+
|
|
342
360
|
// Build messages and collect corresponding entries
|
|
343
361
|
// When there's a compaction, we need to:
|
|
344
362
|
// 1. Emit summary first (entry = compaction)
|
|
@@ -389,7 +407,7 @@ export function buildSessionContext(
|
|
|
389
407
|
}
|
|
390
408
|
}
|
|
391
409
|
|
|
392
|
-
return { messages, thinkingLevel, models };
|
|
410
|
+
return { messages, thinkingLevel, models, injectedTtsrRules };
|
|
393
411
|
}
|
|
394
412
|
|
|
395
413
|
/**
|
|
@@ -814,6 +832,44 @@ export class SessionManager {
|
|
|
814
832
|
return entry.id;
|
|
815
833
|
}
|
|
816
834
|
|
|
835
|
+
// =========================================================================
|
|
836
|
+
// TTSR (Time Traveling Stream Rules)
|
|
837
|
+
// =========================================================================
|
|
838
|
+
|
|
839
|
+
/**
|
|
840
|
+
* Append a TTSR injection entry recording which rules were injected.
|
|
841
|
+
* @param ruleNames Names of rules that were injected
|
|
842
|
+
* @returns Entry id
|
|
843
|
+
*/
|
|
844
|
+
appendTtsrInjection(ruleNames: string[]): string {
|
|
845
|
+
const entry: TtsrInjectionEntry = {
|
|
846
|
+
type: "ttsr_injection",
|
|
847
|
+
id: generateId(this.byId),
|
|
848
|
+
parentId: this.leafId,
|
|
849
|
+
timestamp: new Date().toISOString(),
|
|
850
|
+
injectedRules: ruleNames,
|
|
851
|
+
};
|
|
852
|
+
this._appendEntry(entry);
|
|
853
|
+
return entry.id;
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
/**
|
|
857
|
+
* Get all unique TTSR rule names that have been injected in the current branch.
|
|
858
|
+
* Scans from root to current leaf for ttsr_injection entries.
|
|
859
|
+
*/
|
|
860
|
+
getInjectedTtsrRules(): string[] {
|
|
861
|
+
const path = this.getBranch();
|
|
862
|
+
const ruleNames = new Set<string>();
|
|
863
|
+
for (const entry of path) {
|
|
864
|
+
if (entry.type === "ttsr_injection") {
|
|
865
|
+
for (const name of entry.injectedRules) {
|
|
866
|
+
ruleNames.add(name);
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
return Array.from(ruleNames);
|
|
871
|
+
}
|
|
872
|
+
|
|
817
873
|
// =========================================================================
|
|
818
874
|
// Tree Traversal
|
|
819
875
|
// =========================================================================
|
|
@@ -68,6 +68,16 @@ export interface EditSettings {
|
|
|
68
68
|
fuzzyMatch?: boolean; // default: true (accept high-confidence fuzzy matches for whitespace/indentation)
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
export interface TtsrSettings {
|
|
72
|
+
enabled?: boolean; // default: true
|
|
73
|
+
/** What to do with partial output when TTSR triggers: "keep" shows interrupted attempt, "discard" removes it */
|
|
74
|
+
contextMode?: "keep" | "discard"; // default: "discard"
|
|
75
|
+
/** How TTSR rules repeat: "once" = only trigger once per session, "after-gap" = can repeat after N messages */
|
|
76
|
+
repeatMode?: "once" | "after-gap"; // default: "once"
|
|
77
|
+
/** Number of messages before a rule can trigger again (only used when repeatMode is "after-gap") */
|
|
78
|
+
repeatGap?: number; // default: 10
|
|
79
|
+
}
|
|
80
|
+
|
|
71
81
|
export interface Settings {
|
|
72
82
|
lastChangelogVersion?: string;
|
|
73
83
|
/** Model roles map: { default: "provider/modelId", small: "provider/modelId", ... } */
|
|
@@ -93,6 +103,7 @@ export interface Settings {
|
|
|
93
103
|
mcp?: MCPSettings;
|
|
94
104
|
lsp?: LspSettings;
|
|
95
105
|
edit?: EditSettings;
|
|
106
|
+
ttsr?: TtsrSettings;
|
|
96
107
|
disabledProviders?: string[]; // Discovery provider IDs that are disabled
|
|
97
108
|
}
|
|
98
109
|
|
|
@@ -582,4 +593,61 @@ export class SettingsManager {
|
|
|
582
593
|
this.globalSettings.disabledProviders = providerIds;
|
|
583
594
|
this.save();
|
|
584
595
|
}
|
|
596
|
+
|
|
597
|
+
getTtsrSettings(): TtsrSettings {
|
|
598
|
+
return this.settings.ttsr ?? {};
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
setTtsrSettings(settings: TtsrSettings): void {
|
|
602
|
+
this.globalSettings.ttsr = { ...this.globalSettings.ttsr, ...settings };
|
|
603
|
+
this.save();
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
getTtsrEnabled(): boolean {
|
|
607
|
+
return this.settings.ttsr?.enabled ?? true;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
setTtsrEnabled(enabled: boolean): void {
|
|
611
|
+
if (!this.globalSettings.ttsr) {
|
|
612
|
+
this.globalSettings.ttsr = {};
|
|
613
|
+
}
|
|
614
|
+
this.globalSettings.ttsr.enabled = enabled;
|
|
615
|
+
this.save();
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
getTtsrContextMode(): "keep" | "discard" {
|
|
619
|
+
return this.settings.ttsr?.contextMode ?? "discard";
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
setTtsrContextMode(mode: "keep" | "discard"): void {
|
|
623
|
+
if (!this.globalSettings.ttsr) {
|
|
624
|
+
this.globalSettings.ttsr = {};
|
|
625
|
+
}
|
|
626
|
+
this.globalSettings.ttsr.contextMode = mode;
|
|
627
|
+
this.save();
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
getTtsrRepeatMode(): "once" | "after-gap" {
|
|
631
|
+
return this.settings.ttsr?.repeatMode ?? "once";
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
setTtsrRepeatMode(mode: "once" | "after-gap"): void {
|
|
635
|
+
if (!this.globalSettings.ttsr) {
|
|
636
|
+
this.globalSettings.ttsr = {};
|
|
637
|
+
}
|
|
638
|
+
this.globalSettings.ttsr.repeatMode = mode;
|
|
639
|
+
this.save();
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
getTtsrRepeatGap(): number {
|
|
643
|
+
return this.settings.ttsr?.repeatGap ?? 10;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
setTtsrRepeatGap(gap: number): void {
|
|
647
|
+
if (!this.globalSettings.ttsr) {
|
|
648
|
+
this.globalSettings.ttsr = {};
|
|
649
|
+
}
|
|
650
|
+
this.globalSettings.ttsr.repeatGap = gap;
|
|
651
|
+
this.save();
|
|
652
|
+
}
|
|
585
653
|
}
|
package/src/core/ttsr.ts
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Time Traveling Stream Rules (TTSR) Manager
|
|
3
|
+
*
|
|
4
|
+
* Manages rules that get injected mid-stream when their trigger pattern matches
|
|
5
|
+
* the agent's output. When a match occurs, the stream is aborted, the rule is
|
|
6
|
+
* injected as a system reminder, and the request is retried.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { Rule } from "../capability/rule";
|
|
10
|
+
import { logger } from "./logger";
|
|
11
|
+
import type { TtsrSettings } from "./settings-manager";
|
|
12
|
+
|
|
13
|
+
interface TtsrEntry {
|
|
14
|
+
rule: Rule;
|
|
15
|
+
regex: RegExp;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Tracks when a rule was last injected (for repeat-after-gap mode) */
|
|
19
|
+
interface InjectionRecord {
|
|
20
|
+
/** Message count when the rule was last injected */
|
|
21
|
+
lastInjectedAt: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface TtsrManager {
|
|
25
|
+
/** Add a TTSR rule to be monitored */
|
|
26
|
+
addRule(rule: Rule): void;
|
|
27
|
+
|
|
28
|
+
/** Check if any uninjected TTSR matches the stream buffer. Returns matching rules. */
|
|
29
|
+
check(streamBuffer: string): Rule[];
|
|
30
|
+
|
|
31
|
+
/** Mark rules as injected (won't trigger again until conditions allow) */
|
|
32
|
+
markInjected(rules: Rule[]): void;
|
|
33
|
+
|
|
34
|
+
/** Get names of all injected rules (for persistence) */
|
|
35
|
+
getInjectedRuleNames(): string[];
|
|
36
|
+
|
|
37
|
+
/** Restore injected state from a list of rule names */
|
|
38
|
+
restoreInjected(ruleNames: string[]): void;
|
|
39
|
+
|
|
40
|
+
/** Reset stream buffer (called on new turn) */
|
|
41
|
+
resetBuffer(): void;
|
|
42
|
+
|
|
43
|
+
/** Get current stream buffer */
|
|
44
|
+
getBuffer(): string;
|
|
45
|
+
|
|
46
|
+
/** Append to stream buffer */
|
|
47
|
+
appendToBuffer(text: string): void;
|
|
48
|
+
|
|
49
|
+
/** Check if any TTSRs are registered */
|
|
50
|
+
hasRules(): boolean;
|
|
51
|
+
|
|
52
|
+
/** Increment message counter (call after each turn) */
|
|
53
|
+
incrementMessageCount(): void;
|
|
54
|
+
|
|
55
|
+
/** Get current message count */
|
|
56
|
+
getMessageCount(): number;
|
|
57
|
+
|
|
58
|
+
/** Get settings */
|
|
59
|
+
getSettings(): Required<TtsrSettings>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const DEFAULT_SETTINGS: Required<TtsrSettings> = {
|
|
63
|
+
enabled: true,
|
|
64
|
+
contextMode: "discard",
|
|
65
|
+
repeatMode: "once",
|
|
66
|
+
repeatGap: 10,
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export function createTtsrManager(settings?: TtsrSettings): TtsrManager {
|
|
70
|
+
/** Resolved settings with defaults */
|
|
71
|
+
const resolvedSettings: Required<TtsrSettings> = {
|
|
72
|
+
...DEFAULT_SETTINGS,
|
|
73
|
+
...settings,
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/** Map of rule name -> { rule, compiled regex } */
|
|
77
|
+
const rules = new Map<string, TtsrEntry>();
|
|
78
|
+
|
|
79
|
+
/** Map of rule name -> injection record */
|
|
80
|
+
const injectionRecords = new Map<string, InjectionRecord>();
|
|
81
|
+
|
|
82
|
+
/** Current stream buffer for pattern matching */
|
|
83
|
+
let buffer = "";
|
|
84
|
+
|
|
85
|
+
/** Message counter for tracking gap between injections */
|
|
86
|
+
let messageCount = 0;
|
|
87
|
+
|
|
88
|
+
/** Check if a rule can be triggered based on repeat settings */
|
|
89
|
+
function canTrigger(ruleName: string): boolean {
|
|
90
|
+
const record = injectionRecords.get(ruleName);
|
|
91
|
+
if (!record) {
|
|
92
|
+
// Never injected, can trigger
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (resolvedSettings.repeatMode === "once") {
|
|
97
|
+
// Once mode: never trigger again after first injection
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// After-gap mode: check if enough messages have passed
|
|
102
|
+
const gap = messageCount - record.lastInjectedAt;
|
|
103
|
+
return gap >= resolvedSettings.repeatGap;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
addRule(rule: Rule): void {
|
|
108
|
+
// Only add rules that have a TTSR trigger pattern
|
|
109
|
+
if (!rule.ttsrTrigger) {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Skip if already registered
|
|
114
|
+
if (rules.has(rule.name)) {
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Compile the regex pattern
|
|
119
|
+
try {
|
|
120
|
+
const regex = new RegExp(rule.ttsrTrigger);
|
|
121
|
+
rules.set(rule.name, { rule, regex });
|
|
122
|
+
logger.debug("TTSR rule registered", {
|
|
123
|
+
ruleName: rule.name,
|
|
124
|
+
pattern: rule.ttsrTrigger,
|
|
125
|
+
});
|
|
126
|
+
} catch (err) {
|
|
127
|
+
logger.warn("TTSR rule has invalid regex pattern, skipping", {
|
|
128
|
+
ruleName: rule.name,
|
|
129
|
+
pattern: rule.ttsrTrigger,
|
|
130
|
+
error: err instanceof Error ? err.message : String(err),
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
|
|
135
|
+
check(streamBuffer: string): Rule[] {
|
|
136
|
+
const matches: Rule[] = [];
|
|
137
|
+
|
|
138
|
+
for (const [name, entry] of rules) {
|
|
139
|
+
// Skip rules that can't trigger yet
|
|
140
|
+
if (!canTrigger(name)) {
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Test the buffer against the rule's pattern
|
|
145
|
+
if (entry.regex.test(streamBuffer)) {
|
|
146
|
+
matches.push(entry.rule);
|
|
147
|
+
logger.debug("TTSR pattern matched", {
|
|
148
|
+
ruleName: name,
|
|
149
|
+
pattern: entry.rule.ttsrTrigger,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return matches;
|
|
155
|
+
},
|
|
156
|
+
|
|
157
|
+
markInjected(rulesToMark: Rule[]): void {
|
|
158
|
+
for (const rule of rulesToMark) {
|
|
159
|
+
injectionRecords.set(rule.name, { lastInjectedAt: messageCount });
|
|
160
|
+
logger.debug("TTSR rule marked as injected", {
|
|
161
|
+
ruleName: rule.name,
|
|
162
|
+
messageCount,
|
|
163
|
+
repeatMode: resolvedSettings.repeatMode,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
|
|
168
|
+
getInjectedRuleNames(): string[] {
|
|
169
|
+
return Array.from(injectionRecords.keys());
|
|
170
|
+
},
|
|
171
|
+
|
|
172
|
+
restoreInjected(ruleNames: string[]): void {
|
|
173
|
+
// When restoring, we don't know the original message count, so use 0
|
|
174
|
+
// This means in "after-gap" mode, rules can trigger again after the gap
|
|
175
|
+
for (const name of ruleNames) {
|
|
176
|
+
injectionRecords.set(name, { lastInjectedAt: 0 });
|
|
177
|
+
}
|
|
178
|
+
if (ruleNames.length > 0) {
|
|
179
|
+
logger.debug("TTSR injected state restored", { ruleNames });
|
|
180
|
+
}
|
|
181
|
+
},
|
|
182
|
+
|
|
183
|
+
resetBuffer(): void {
|
|
184
|
+
buffer = "";
|
|
185
|
+
},
|
|
186
|
+
|
|
187
|
+
getBuffer(): string {
|
|
188
|
+
return buffer;
|
|
189
|
+
},
|
|
190
|
+
|
|
191
|
+
appendToBuffer(text: string): void {
|
|
192
|
+
buffer += text;
|
|
193
|
+
},
|
|
194
|
+
|
|
195
|
+
hasRules(): boolean {
|
|
196
|
+
return rules.size > 0;
|
|
197
|
+
},
|
|
198
|
+
|
|
199
|
+
incrementMessageCount(): void {
|
|
200
|
+
messageCount++;
|
|
201
|
+
},
|
|
202
|
+
|
|
203
|
+
getMessageCount(): number {
|
|
204
|
+
return messageCount;
|
|
205
|
+
},
|
|
206
|
+
|
|
207
|
+
getSettings(): Required<TtsrSettings> {
|
|
208
|
+
return resolvedSettings;
|
|
209
|
+
},
|
|
210
|
+
};
|
|
211
|
+
}
|
package/src/discovery/builtin.ts
CHANGED
|
@@ -310,6 +310,7 @@ function loadRules(ctx: LoadContext): LoadResult<Rule> {
|
|
|
310
310
|
globs: frontmatter.globs as string[] | undefined,
|
|
311
311
|
alwaysApply: frontmatter.alwaysApply as boolean | undefined,
|
|
312
312
|
description: frontmatter.description as string | undefined,
|
|
313
|
+
ttsrTrigger: typeof frontmatter.ttsr_trigger === "string" ? frontmatter.ttsr_trigger : undefined,
|
|
313
314
|
_source: source,
|
|
314
315
|
};
|
|
315
316
|
},
|
package/src/discovery/cline.ts
CHANGED
|
@@ -52,6 +52,7 @@ function loadRules(ctx: LoadContext): LoadResult<Rule> {
|
|
|
52
52
|
globs,
|
|
53
53
|
alwaysApply: typeof frontmatter.alwaysApply === "boolean" ? frontmatter.alwaysApply : undefined,
|
|
54
54
|
description: typeof frontmatter.description === "string" ? frontmatter.description : undefined,
|
|
55
|
+
ttsrTrigger: typeof frontmatter.ttsr_trigger === "string" ? frontmatter.ttsr_trigger : undefined,
|
|
55
56
|
_source: source,
|
|
56
57
|
};
|
|
57
58
|
},
|
|
@@ -85,6 +86,7 @@ function loadRules(ctx: LoadContext): LoadResult<Rule> {
|
|
|
85
86
|
globs,
|
|
86
87
|
alwaysApply: typeof frontmatter.alwaysApply === "boolean" ? frontmatter.alwaysApply : undefined,
|
|
87
88
|
description: typeof frontmatter.description === "string" ? frontmatter.description : undefined,
|
|
89
|
+
ttsrTrigger: typeof frontmatter.ttsr_trigger === "string" ? frontmatter.ttsr_trigger : undefined,
|
|
88
90
|
_source: source,
|
|
89
91
|
});
|
|
90
92
|
}
|
package/src/discovery/cursor.ts
CHANGED
|
@@ -163,6 +163,7 @@ function transformMDCRule(
|
|
|
163
163
|
// Extract frontmatter fields
|
|
164
164
|
const description = typeof frontmatter.description === "string" ? frontmatter.description : undefined;
|
|
165
165
|
const alwaysApply = frontmatter.alwaysApply === true;
|
|
166
|
+
const ttsrTrigger = typeof frontmatter.ttsr_trigger === "string" ? frontmatter.ttsr_trigger : undefined;
|
|
166
167
|
|
|
167
168
|
// Parse globs (can be array or single string)
|
|
168
169
|
let globs: string[] | undefined;
|
|
@@ -182,6 +183,7 @@ function transformMDCRule(
|
|
|
182
183
|
description,
|
|
183
184
|
alwaysApply,
|
|
184
185
|
globs,
|
|
186
|
+
ttsrTrigger,
|
|
185
187
|
_source: source,
|
|
186
188
|
};
|
|
187
189
|
}
|
|
@@ -128,6 +128,7 @@ function loadRules(ctx: LoadContext): LoadResult<Rule> {
|
|
|
128
128
|
globs,
|
|
129
129
|
alwaysApply: frontmatter.alwaysApply as boolean | undefined,
|
|
130
130
|
description: frontmatter.description as string | undefined,
|
|
131
|
+
ttsrTrigger: typeof frontmatter.ttsr_trigger === "string" ? frontmatter.ttsr_trigger : undefined,
|
|
131
132
|
_source: createSourceMeta(PROVIDER_ID, userPath, "user"),
|
|
132
133
|
});
|
|
133
134
|
}
|
|
@@ -157,6 +158,7 @@ function loadRules(ctx: LoadContext): LoadResult<Rule> {
|
|
|
157
158
|
globs,
|
|
158
159
|
alwaysApply: frontmatter.alwaysApply as boolean | undefined,
|
|
159
160
|
description: frontmatter.description as string | undefined,
|
|
161
|
+
ttsrTrigger: typeof frontmatter.ttsr_trigger === "string" ? frontmatter.ttsr_trigger : undefined,
|
|
160
162
|
_source: source,
|
|
161
163
|
};
|
|
162
164
|
},
|
|
@@ -187,6 +189,7 @@ function loadRules(ctx: LoadContext): LoadResult<Rule> {
|
|
|
187
189
|
globs,
|
|
188
190
|
alwaysApply: frontmatter.alwaysApply as boolean | undefined,
|
|
189
191
|
description: frontmatter.description as string | undefined,
|
|
192
|
+
ttsrTrigger: typeof frontmatter.ttsr_trigger === "string" ? frontmatter.ttsr_trigger : undefined,
|
|
190
193
|
_source: createSourceMeta(PROVIDER_ID, legacyPath, "project"),
|
|
191
194
|
});
|
|
192
195
|
}
|
|
@@ -58,6 +58,10 @@ export class FooterComponent implements Component {
|
|
|
58
58
|
private autoCompactEnabled: boolean = true;
|
|
59
59
|
private hookStatuses: Map<string, string> = new Map();
|
|
60
60
|
|
|
61
|
+
// Git status caching (1s TTL to avoid excessive subprocess spawns)
|
|
62
|
+
private cachedGitStatus: { staged: number; unstaged: number; untracked: number } | null = null;
|
|
63
|
+
private gitStatusLastFetch = 0;
|
|
64
|
+
|
|
61
65
|
constructor(session: AgentSession) {
|
|
62
66
|
this.session = session;
|
|
63
67
|
}
|
|
@@ -165,8 +169,14 @@ export class FooterComponent implements Component {
|
|
|
165
169
|
/**
|
|
166
170
|
* Get git status indicators (staged, unstaged, untracked counts).
|
|
167
171
|
* Returns null if not in a git repo.
|
|
172
|
+
* Cached for 1s to avoid excessive subprocess spawns.
|
|
168
173
|
*/
|
|
169
174
|
private getGitStatus(): { staged: number; unstaged: number; untracked: number } | null {
|
|
175
|
+
const now = Date.now();
|
|
176
|
+
if (now - this.gitStatusLastFetch < 1000) {
|
|
177
|
+
return this.cachedGitStatus;
|
|
178
|
+
}
|
|
179
|
+
|
|
170
180
|
try {
|
|
171
181
|
const output = execSync("git status --porcelain 2>/dev/null", {
|
|
172
182
|
encoding: "utf8",
|
|
@@ -200,8 +210,12 @@ export class FooterComponent implements Component {
|
|
|
200
210
|
}
|
|
201
211
|
}
|
|
202
212
|
|
|
203
|
-
|
|
213
|
+
this.cachedGitStatus = { staged, unstaged, untracked };
|
|
214
|
+
this.gitStatusLastFetch = now;
|
|
215
|
+
return this.cachedGitStatus;
|
|
204
216
|
} catch {
|
|
217
|
+
this.cachedGitStatus = null;
|
|
218
|
+
this.gitStatusLastFetch = now;
|
|
205
219
|
return null;
|
|
206
220
|
}
|
|
207
221
|
}
|
|
@@ -155,6 +155,35 @@ export const SETTINGS_DEFS: SettingDef[] = [
|
|
|
155
155
|
get: (sm) => sm.getEditFuzzyMatch(),
|
|
156
156
|
set: (sm, v) => sm.setEditFuzzyMatch(v),
|
|
157
157
|
},
|
|
158
|
+
{
|
|
159
|
+
id: "ttsrEnabled",
|
|
160
|
+
tab: "config",
|
|
161
|
+
type: "boolean",
|
|
162
|
+
label: "TTSR enabled",
|
|
163
|
+
description: "Time Traveling Stream Rules: interrupt agent when output matches rule patterns",
|
|
164
|
+
get: (sm) => sm.getTtsrEnabled(),
|
|
165
|
+
set: (sm, v) => sm.setTtsrEnabled(v),
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
id: "ttsrContextMode",
|
|
169
|
+
tab: "config",
|
|
170
|
+
type: "enum",
|
|
171
|
+
label: "TTSR context mode",
|
|
172
|
+
description: "What to do with partial output when TTSR triggers",
|
|
173
|
+
values: ["discard", "keep"],
|
|
174
|
+
get: (sm) => sm.getTtsrContextMode(),
|
|
175
|
+
set: (sm, v) => sm.setTtsrContextMode(v as "keep" | "discard"),
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
id: "ttsrRepeatMode",
|
|
179
|
+
tab: "config",
|
|
180
|
+
type: "enum",
|
|
181
|
+
label: "TTSR repeat mode",
|
|
182
|
+
description: "How rules can repeat: once per session or after a message gap",
|
|
183
|
+
values: ["once", "after-gap"],
|
|
184
|
+
get: (sm) => sm.getTtsrRepeatMode(),
|
|
185
|
+
set: (sm, v) => sm.setTtsrRepeatMode(v as "once" | "after-gap"),
|
|
186
|
+
},
|
|
158
187
|
{
|
|
159
188
|
id: "thinkingLevel",
|
|
160
189
|
tab: "config",
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { Box, Container, Spacer, Text } from "@oh-my-pi/pi-tui";
|
|
2
|
+
import type { Rule } from "../../../capability/rule";
|
|
3
|
+
import { theme } from "../theme/theme";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Component that renders a TTSR (Time Traveling Stream Rules) notification.
|
|
7
|
+
* Shows when a rule violation is detected and the stream is being rewound.
|
|
8
|
+
*/
|
|
9
|
+
export class TtsrNotificationComponent extends Container {
|
|
10
|
+
private rules: Rule[];
|
|
11
|
+
private box: Box;
|
|
12
|
+
private _expanded = false;
|
|
13
|
+
|
|
14
|
+
constructor(rules: Rule[]) {
|
|
15
|
+
super();
|
|
16
|
+
this.rules = rules;
|
|
17
|
+
|
|
18
|
+
this.addChild(new Spacer(1));
|
|
19
|
+
|
|
20
|
+
// Use inverse warning color for yellow background effect
|
|
21
|
+
this.box = new Box(1, 1, (t) => theme.inverse(theme.fg("warning", t)));
|
|
22
|
+
this.addChild(this.box);
|
|
23
|
+
|
|
24
|
+
this.rebuild();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
setExpanded(expanded: boolean): void {
|
|
28
|
+
if (this._expanded !== expanded) {
|
|
29
|
+
this._expanded = expanded;
|
|
30
|
+
this.rebuild();
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
isExpanded(): boolean {
|
|
35
|
+
return this._expanded;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
private rebuild(): void {
|
|
39
|
+
this.box.clear();
|
|
40
|
+
|
|
41
|
+
// Build header: ⚠ Injecting <bold>rule-name</bold> ↩
|
|
42
|
+
const ruleNames = this.rules.map((r) => theme.bold(r.name)).join(", ");
|
|
43
|
+
const label = this.rules.length === 1 ? "rule" : "rules";
|
|
44
|
+
const header = `\u26A0 Injecting ${label}: ${ruleNames}`;
|
|
45
|
+
|
|
46
|
+
// Create header with rewind icon on the right
|
|
47
|
+
const rewindIcon = "\u21A9"; // ↩
|
|
48
|
+
|
|
49
|
+
this.box.addChild(new Text(`${header} ${rewindIcon}`, 0, 0));
|
|
50
|
+
|
|
51
|
+
// Show description(s) - italic and truncated
|
|
52
|
+
for (const rule of this.rules) {
|
|
53
|
+
const desc = rule.description || rule.content;
|
|
54
|
+
if (desc) {
|
|
55
|
+
this.box.addChild(new Spacer(1));
|
|
56
|
+
|
|
57
|
+
let displayText = desc.trim();
|
|
58
|
+
if (!this._expanded) {
|
|
59
|
+
// Truncate to first 2 lines
|
|
60
|
+
const lines = displayText.split("\n");
|
|
61
|
+
if (lines.length > 2) {
|
|
62
|
+
displayText = `${lines.slice(0, 2).join("\n")}...`;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Use italic for subtle distinction (fg colors conflict with inverse)
|
|
67
|
+
this.box.addChild(new Text(theme.italic(displayText), 0, 0));
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Show expand hint if collapsed and there's more content
|
|
72
|
+
if (!this._expanded) {
|
|
73
|
+
const hasMoreContent = this.rules.some((r) => {
|
|
74
|
+
const desc = r.description || r.content;
|
|
75
|
+
return desc && desc.split("\n").length > 2;
|
|
76
|
+
});
|
|
77
|
+
if (hasMoreContent) {
|
|
78
|
+
this.box.addChild(new Text(theme.italic(" (ctrl+o to expand)"), 0, 0));
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -67,6 +67,7 @@ import { SessionSelectorComponent } from "./components/session-selector";
|
|
|
67
67
|
import { SettingsSelectorComponent } from "./components/settings-selector";
|
|
68
68
|
import { ToolExecutionComponent } from "./components/tool-execution";
|
|
69
69
|
import { TreeSelectorComponent } from "./components/tree-selector";
|
|
70
|
+
import { TtsrNotificationComponent } from "./components/ttsr-notification";
|
|
70
71
|
import { UserMessageComponent } from "./components/user-message";
|
|
71
72
|
import { UserMessageSelectorComponent } from "./components/user-message-selector";
|
|
72
73
|
import { WelcomeComponent } from "./components/welcome";
|
|
@@ -1007,18 +1008,28 @@ export class InteractiveMode {
|
|
|
1007
1008
|
if (event.message.role === "user") break;
|
|
1008
1009
|
if (this.streamingComponent && event.message.role === "assistant") {
|
|
1009
1010
|
this.streamingMessage = event.message;
|
|
1010
|
-
|
|
1011
|
+
// Don't show "Aborted" text for TTSR aborts - we'll show a nicer message
|
|
1012
|
+
if (this.session.isTtsrAbortPending && this.streamingMessage.stopReason === "aborted") {
|
|
1013
|
+
// TTSR abort - suppress the "Aborted" rendering in the component
|
|
1014
|
+
const msgWithoutAbort = { ...this.streamingMessage, stopReason: "stop" as const };
|
|
1015
|
+
this.streamingComponent.updateContent(msgWithoutAbort);
|
|
1016
|
+
} else {
|
|
1017
|
+
this.streamingComponent.updateContent(this.streamingMessage);
|
|
1018
|
+
}
|
|
1011
1019
|
|
|
1012
1020
|
if (this.streamingMessage.stopReason === "aborted" || this.streamingMessage.stopReason === "error") {
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1021
|
+
// Skip error handling for TTSR aborts
|
|
1022
|
+
if (!this.session.isTtsrAbortPending) {
|
|
1023
|
+
const errorMessage =
|
|
1024
|
+
this.streamingMessage.stopReason === "aborted"
|
|
1025
|
+
? "Operation aborted"
|
|
1026
|
+
: this.streamingMessage.errorMessage || "Error";
|
|
1027
|
+
for (const [, component] of this.pendingTools.entries()) {
|
|
1028
|
+
component.updateResult({
|
|
1029
|
+
content: [{ type: "text", text: errorMessage }],
|
|
1030
|
+
isError: true,
|
|
1031
|
+
});
|
|
1032
|
+
}
|
|
1022
1033
|
}
|
|
1023
1034
|
this.pendingTools.clear();
|
|
1024
1035
|
} else {
|
|
@@ -1188,6 +1199,15 @@ export class InteractiveMode {
|
|
|
1188
1199
|
this.ui.requestRender();
|
|
1189
1200
|
break;
|
|
1190
1201
|
}
|
|
1202
|
+
|
|
1203
|
+
case "ttsr_triggered": {
|
|
1204
|
+
// Show a fancy notification when TTSR rules are triggered
|
|
1205
|
+
const component = new TtsrNotificationComponent(event.rules);
|
|
1206
|
+
component.setExpanded(this.toolOutputExpanded);
|
|
1207
|
+
this.chatContainer.addChild(component);
|
|
1208
|
+
this.ui.requestRender();
|
|
1209
|
+
break;
|
|
1210
|
+
}
|
|
1191
1211
|
}
|
|
1192
1212
|
}
|
|
1193
1213
|
|
|
@@ -2490,9 +2510,7 @@ export class InteractiveMode {
|
|
|
2490
2510
|
const nameRendered = sourceText ? `${theme.bold(nameText)} ${sourceRendered}` : theme.bold(nameText);
|
|
2491
2511
|
const pad = Math.max(0, maxNameWidth - visibleWidth(nameWithSourcePlain));
|
|
2492
2512
|
const desc = line.desc;
|
|
2493
|
-
const descPart = desc
|
|
2494
|
-
? ` ${theme.fg("dim", desc.slice(0, 50) + (desc.length > 50 ? "..." : ""))}`
|
|
2495
|
-
: "";
|
|
2513
|
+
const descPart = desc ? ` ${theme.fg("dim", desc.slice(0, 50) + (desc.length > 50 ? "..." : ""))}` : "";
|
|
2496
2514
|
return ` ${nameRendered}${" ".repeat(pad)}${descPart}`;
|
|
2497
2515
|
});
|
|
2498
2516
|
|
|
@@ -2536,15 +2554,13 @@ export class InteractiveMode {
|
|
|
2536
2554
|
(s) => (s._source ? { provider: s._source.providerName, level: s._source.level } : "unknown"),
|
|
2537
2555
|
);
|
|
2538
2556
|
if (skillWarnings.length > 0) {
|
|
2539
|
-
sections.push(
|
|
2540
|
-
|
|
2541
|
-
|
|
2542
|
-
|
|
2543
|
-
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
},
|
|
2547
|
-
);
|
|
2557
|
+
sections.push({
|
|
2558
|
+
kind: "text",
|
|
2559
|
+
text:
|
|
2560
|
+
theme.bold(theme.fg("warning", "Skill Warnings")) +
|
|
2561
|
+
"\n" +
|
|
2562
|
+
skillWarnings.map((w) => theme.fg("warning", ` ${w.skillPath}: ${w.message}`)).join("\n"),
|
|
2563
|
+
});
|
|
2548
2564
|
}
|
|
2549
2565
|
}
|
|
2550
2566
|
|
|
@@ -2633,14 +2649,12 @@ export class InteractiveMode {
|
|
|
2633
2649
|
if (hookRunner) {
|
|
2634
2650
|
const hookPaths = hookRunner.getHookPaths();
|
|
2635
2651
|
if (hookPaths.length > 0) {
|
|
2636
|
-
sections.push(
|
|
2637
|
-
|
|
2638
|
-
|
|
2639
|
-
|
|
2640
|
-
|
|
2641
|
-
|
|
2642
|
-
},
|
|
2643
|
-
);
|
|
2652
|
+
sections.push({
|
|
2653
|
+
kind: "text",
|
|
2654
|
+
text:
|
|
2655
|
+
`${theme.bold(theme.fg("accent", "Hooks"))}\n` +
|
|
2656
|
+
hookPaths.map((p) => ` ${theme.bold(basename(p))} ${theme.fg("dim", "hook")}`).join("\n"),
|
|
2657
|
+
});
|
|
2644
2658
|
}
|
|
2645
2659
|
}
|
|
2646
2660
|
|
|
@@ -2652,9 +2666,7 @@ export class InteractiveMode {
|
|
|
2652
2666
|
? Math.min(60, Math.max(...allLines.map((line) => visibleWidth(line.nameWithSource))))
|
|
2653
2667
|
: 0;
|
|
2654
2668
|
const renderedSections = sections
|
|
2655
|
-
.map((section) =>
|
|
2656
|
-
section.kind === "lines" ? renderLineSection(section.section, maxNameWidth) : section.text,
|
|
2657
|
-
)
|
|
2669
|
+
.map((section) => (section.kind === "lines" ? renderLineSection(section.section, maxNameWidth) : section.text))
|
|
2658
2670
|
.filter((section) => section.length > 0);
|
|
2659
2671
|
|
|
2660
2672
|
if (renderedSections.length === 0) {
|