pi-minimalist 0.1.0
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/LICENSE +21 -0
- package/README.md +24 -0
- package/index.ts +132 -0
- package/package.json +21 -0
- package/src/bridge.ts +124 -0
- package/src/components.ts +173 -0
- package/src/config-file.ts +277 -0
- package/src/config-ui.ts +208 -0
- package/src/config.ts +246 -0
- package/src/core-patch.ts +510 -0
- package/src/row.ts +193 -0
- package/src/run-grouping.ts +378 -0
- package/src/thinking.ts +100 -0
- package/src/tool-renderer.ts +260 -0
- package/src/tools.ts +195 -0
package/src/row.ts
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The row data model, and the ONLY place that turns a row into colored text.
|
|
3
|
+
*
|
|
4
|
+
* TWO RULES THIS FILE ENFORCES
|
|
5
|
+
* ---------------------------
|
|
6
|
+
* 1. Rows are described as DATA and painted exactly once. Nothing downstream may
|
|
7
|
+
* re-parse painted text. An earlier version built `"read src/a.ts"` and then
|
|
8
|
+
* split it on the first space to recolor the action word, which silently
|
|
9
|
+
* assumed no label ever contains a space; run summaries were joined into
|
|
10
|
+
* `"read ×2, edit ×1"` and split back apart the same way.
|
|
11
|
+
*
|
|
12
|
+
* 2. Theme and settings are bound ONCE, in a `Painter`. There used to be a
|
|
13
|
+
* module-level mutable config with `config = defaultConfig()` defaults on four
|
|
14
|
+
* functions, which made painting depend on load order and let one test leak
|
|
15
|
+
* settings into the next.
|
|
16
|
+
*
|
|
17
|
+
* Free of Pi extension-API runtime imports, so tests exercise real painting
|
|
18
|
+
* without an extension runtime.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
// Type-only: erased at runtime, but makes a mistyped token ("succes") a compile
|
|
22
|
+
// error instead of a theme.fg() throw that core turns into a verbose card.
|
|
23
|
+
import type { Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
24
|
+
import type { Config } from "./config.ts";
|
|
25
|
+
|
|
26
|
+
// ThemeBg is not exported publicly, so recover it from Theme.bg's own signature
|
|
27
|
+
// rather than importing a deep dist path (which is not a package export).
|
|
28
|
+
type ThemeBg = Parameters<Theme["bg"]>[0];
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Minimal theme surface. Pi's real Theme satisfies it; tests pass a fake that
|
|
32
|
+
* renders `<token>text</token>` so assertions name the token instead of
|
|
33
|
+
* depending on the machine's palette.
|
|
34
|
+
*
|
|
35
|
+
* Method shorthand (not property-with-function-type) on purpose: methods are
|
|
36
|
+
* bivariant, so Pi's Theme stays assignable under strictFunctionTypes.
|
|
37
|
+
*/
|
|
38
|
+
export type ThemeLike = {
|
|
39
|
+
fg(token: ThemeColor, text: string): string;
|
|
40
|
+
bg(token: ThemeBg, text: string): string;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** One painted row. `text` is already colored; truncation happens at render. */
|
|
44
|
+
export type Row = {
|
|
45
|
+
/** Pre-colored left marker, or "" when the gutter is disabled. */
|
|
46
|
+
gutter: string;
|
|
47
|
+
/** Fully colored row content. */
|
|
48
|
+
text: string;
|
|
49
|
+
/** Full-width background, applied after truncation. */
|
|
50
|
+
highlight?: (text: string) => string;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Visible columns a gutter occupies: one space, the glyph, one space.
|
|
55
|
+
*
|
|
56
|
+
* DERIVED from the row's own gutter text, never configured. An earlier design
|
|
57
|
+
* exported this as a constant, which could disagree with the real glyph width and
|
|
58
|
+
* shift every row.
|
|
59
|
+
*/
|
|
60
|
+
export function gutterWidth(gutter: string): number {
|
|
61
|
+
return gutter === "" ? 0 : 3;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* A `glyph label [badge] details` row — the shared grammar of tool calls
|
|
66
|
+
* (`✓ read src/a.ts`) and thinking previews (`• think The user wants…`).
|
|
67
|
+
* Segments are colored independently and joined with single spaces; an absent
|
|
68
|
+
* segment leaves no stray space behind.
|
|
69
|
+
*/
|
|
70
|
+
export type Labeled = {
|
|
71
|
+
/** Status glyph: `›` queued, `•` running, `✓` done, `✗` failed. */
|
|
72
|
+
glyph?: string;
|
|
73
|
+
glyphColor?: ThemeColor;
|
|
74
|
+
/** Action word: a tool name, or "think". Also picks the gutter color. */
|
|
75
|
+
label: string;
|
|
76
|
+
labelColor?: ThemeColor;
|
|
77
|
+
/** Elapsed-timer badge, between label and details. */
|
|
78
|
+
badge?: string;
|
|
79
|
+
/** Path, command, or preview text. */
|
|
80
|
+
details?: string;
|
|
81
|
+
/** Highlight the row background (streaming thinking only). */
|
|
82
|
+
highlight?: boolean;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/** One folded name and how many times it appeared in a run. */
|
|
86
|
+
export type Count = { name: string; count: number };
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* What a folded run collapses to, grouped by outcome.
|
|
90
|
+
*
|
|
91
|
+
* `running` is a group of its own because a still-running row folds by default.
|
|
92
|
+
* Without it, a folded running call was counted nowhere and simply vanished from
|
|
93
|
+
* the transcript until it finished.
|
|
94
|
+
*
|
|
95
|
+
* An object rather than three positional arrays: they are the same type, so
|
|
96
|
+
* transposing two of them would have been silent.
|
|
97
|
+
*/
|
|
98
|
+
export type RunSummary = {
|
|
99
|
+
done: Count[];
|
|
100
|
+
failed: Count[];
|
|
101
|
+
running: Count[];
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
export const EMPTY_SUMMARY: RunSummary = { done: [], failed: [], running: [] };
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Paints rows for one theme and one settings object.
|
|
108
|
+
*
|
|
109
|
+
* Created per render pass, so `/theme` and every setting toggle are picked up
|
|
110
|
+
* without any shared mutable state.
|
|
111
|
+
*/
|
|
112
|
+
export class Painter {
|
|
113
|
+
private theme: ThemeLike;
|
|
114
|
+
private config: Config;
|
|
115
|
+
|
|
116
|
+
constructor(theme: ThemeLike, config: Config) {
|
|
117
|
+
this.theme = theme;
|
|
118
|
+
this.config = config;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Vertical bar at the left of a compact row. Model prose stays flush left, so a
|
|
123
|
+
* run of tool rows reads as one indented block instead of same-weight lines
|
|
124
|
+
* mixed into text. Indented one column so the bar sits inside the text area.
|
|
125
|
+
*/
|
|
126
|
+
gutter(token: ThemeColor = this.config.tokens().label): string {
|
|
127
|
+
if (!this.config.get("gutter")) return "";
|
|
128
|
+
return ` ${this.theme.fg(token, this.config.glyphs().gutter)} `;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Gutter for expanded OUTPUT lines: same glyph and column as the call row but
|
|
133
|
+
* dimmed, so the call row still reads as the block header.
|
|
134
|
+
*/
|
|
135
|
+
outputGutter(): string {
|
|
136
|
+
if (!this.config.get("gutter")) return "";
|
|
137
|
+
const { outputGutter } = this.config.tokens();
|
|
138
|
+
return ` ${this.theme.fg(outputGutter, this.config.glyphs().gutter)} `;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
labeled(spec: Labeled): Row {
|
|
142
|
+
const tokens = this.config.tokens();
|
|
143
|
+
const labelColor = spec.labelColor ?? tokens.label;
|
|
144
|
+
const parts: string[] = [];
|
|
145
|
+
if (spec.glyph) parts.push(this.theme.fg(spec.glyphColor ?? tokens.label, spec.glyph));
|
|
146
|
+
parts.push(this.theme.fg(labelColor, spec.label));
|
|
147
|
+
if (spec.badge) parts.push(this.theme.fg(tokens.label, spec.badge));
|
|
148
|
+
if (spec.details) parts.push(this.theme.fg(tokens.details, spec.details));
|
|
149
|
+
return {
|
|
150
|
+
// The gutter follows the label's hue, so thinking rows stay purple and tool
|
|
151
|
+
// rows stay green without either caller naming a gutter color.
|
|
152
|
+
gutter: this.gutter(labelColor),
|
|
153
|
+
text: parts.join(" "),
|
|
154
|
+
highlight: spec.highlight ? (text) => this.theme.bg(tokens.activeBg, text) : undefined,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The single line a folded run collapses to, e.g.
|
|
160
|
+
* `✓ read ×2, edit ×1 · ✗ bash ×1 · • bash ×1`.
|
|
161
|
+
*
|
|
162
|
+
* Always label-colored, even when it replaces a purple thinking row: the
|
|
163
|
+
* summary belongs to the run, not to one of its members.
|
|
164
|
+
*/
|
|
165
|
+
summary(summary: RunSummary): Row {
|
|
166
|
+
const { label, details, error } = this.config.tokens();
|
|
167
|
+
const glyphs = this.config.glyphs();
|
|
168
|
+
const groups = [
|
|
169
|
+
{ counts: summary.done, glyph: glyphs.done, token: label },
|
|
170
|
+
{ counts: summary.failed, glyph: glyphs.failed, token: error },
|
|
171
|
+
{ counts: summary.running, glyph: glyphs.running, token: label },
|
|
172
|
+
]
|
|
173
|
+
.filter((group) => group.counts.length > 0)
|
|
174
|
+
.map((group) => `${this.theme.fg(group.token, group.glyph)} ${this.counts(group.counts, group.token)}`);
|
|
175
|
+
|
|
176
|
+
return { gutter: this.gutter(label), text: groups.join(this.theme.fg(details, " · ")) };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private counts(counts: Count[], color: ThemeColor): string {
|
|
180
|
+
const tokens = this.config.tokens();
|
|
181
|
+
const glyphs = this.config.glyphs();
|
|
182
|
+
return counts
|
|
183
|
+
.map(({ name, count }) => {
|
|
184
|
+
// Thinking keeps its own hue inside a summary — unless it is configured to
|
|
185
|
+
// behave exactly like a tool, in which case it should look like one too.
|
|
186
|
+
const keepsThinkingHue =
|
|
187
|
+
name === "think" && color === tokens.label && !this.config.get("thinkingAsToolCall");
|
|
188
|
+
const token = keepsThinkingHue ? tokens.thinking : color;
|
|
189
|
+
return `${this.theme.fg(token, name)} ${this.theme.fg(tokens.details, `${glyphs.count}${count}`)}`;
|
|
190
|
+
})
|
|
191
|
+
.join(this.theme.fg(tokens.details, ", "));
|
|
192
|
+
}
|
|
193
|
+
}
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Folding adjacent low-noise rows into one summary (the `groupToolRuns` setting).
|
|
3
|
+
*
|
|
4
|
+
* Formerly `quiet-mode.ts` / `QuietMode`, named after a `/quiet` command that no
|
|
5
|
+
* longer exists.
|
|
6
|
+
*
|
|
7
|
+
* WHY THIS STATE EXISTS AT ALL
|
|
8
|
+
* ---------------------------
|
|
9
|
+
* Each tool row is an independent transcript entry that knows nothing about its
|
|
10
|
+
* neighbours, but folding is a question about NEIGHBOURS. Their renderers are
|
|
11
|
+
* constructed in transcript order, so recording that order here lets every row
|
|
12
|
+
* decide AT RENDER TIME whether it is hidden, unchanged, or the visible tail of a
|
|
13
|
+
* folded run. That is what makes a settings toggle re-fold existing history with
|
|
14
|
+
* no core rebuild and no core patch.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { Config } from "./config.ts";
|
|
18
|
+
import type { Count, Painter, Row, RunSummary } from "./row.ts";
|
|
19
|
+
|
|
20
|
+
export type Outcome = "success" | "failure" | "pending";
|
|
21
|
+
|
|
22
|
+
type EntryKind = "tool" | "thinking" | "prose";
|
|
23
|
+
|
|
24
|
+
type Entry = {
|
|
25
|
+
id: string;
|
|
26
|
+
name: string;
|
|
27
|
+
outcome: Outcome;
|
|
28
|
+
kind: EntryKind;
|
|
29
|
+
cycle: number;
|
|
30
|
+
/** The user expanded this row (Ctrl+O), so it must stay visible. */
|
|
31
|
+
expanded: boolean;
|
|
32
|
+
/** False for rows that are never folded, e.g. assistant prose. */
|
|
33
|
+
foldable: boolean;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
type Cycle = { startedAt: number; settledAt?: number; finalAnswerStarted: boolean };
|
|
37
|
+
|
|
38
|
+
/** "show" keeps the row as-is, "hide" draws nothing, a summary folds a whole run. */
|
|
39
|
+
export type RowView = "show" | "hide" | RunSummary;
|
|
40
|
+
type ActivityState = "show" | "hide" | "summary";
|
|
41
|
+
type ViewCache = {
|
|
42
|
+
key: string;
|
|
43
|
+
activity: Map<string, ActivityState>;
|
|
44
|
+
grouped: Map<string, RowView>;
|
|
45
|
+
actions: Map<number, RunSummary>;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export class RunGrouping {
|
|
49
|
+
private config: Config;
|
|
50
|
+
private entries: Entry[] = [];
|
|
51
|
+
private byId = new Map<string, Entry>();
|
|
52
|
+
private ownerIds = new WeakMap<object, number>();
|
|
53
|
+
private ownerEntries = new Map<number, Set<string>>();
|
|
54
|
+
private nextOwnerId = 1;
|
|
55
|
+
private cycle = 0;
|
|
56
|
+
private cycles = new Map<number, Cycle>();
|
|
57
|
+
private agentActive = false;
|
|
58
|
+
private version = 0;
|
|
59
|
+
private cache?: ViewCache;
|
|
60
|
+
|
|
61
|
+
constructor(config: Config) {
|
|
62
|
+
this.config = config;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
get enabled(): boolean {
|
|
66
|
+
return this.config.get("groupToolRuns");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Start/finish one user-prompt agent cycle without discarding old transcript state. */
|
|
70
|
+
agentStarted(now = Date.now()): void {
|
|
71
|
+
// Replay leaves `cycle` pointing at the next empty slot. A subsequent live
|
|
72
|
+
// interaction can use it directly; consecutive live interactions advance.
|
|
73
|
+
if (this.entries.some((entry) => entry.cycle === this.cycle)) this.cycle++;
|
|
74
|
+
this.cycles.set(this.cycle, { startedAt: now, finalAnswerStarted: false });
|
|
75
|
+
this.agentActive = true;
|
|
76
|
+
this.invalidateViews();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
agentSettled(now = Date.now()): void {
|
|
80
|
+
const cycle = this.cycles.get(this.cycle);
|
|
81
|
+
if (cycle) cycle.settledAt = now;
|
|
82
|
+
this.agentActive = false;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Record the current row state. Re-renders update one stable entry in place. */
|
|
86
|
+
observe(
|
|
87
|
+
id: string,
|
|
88
|
+
name: string,
|
|
89
|
+
outcome: Outcome,
|
|
90
|
+
expanded: boolean,
|
|
91
|
+
foldable = true,
|
|
92
|
+
kind: EntryKind = "tool",
|
|
93
|
+
): void {
|
|
94
|
+
const existing = this.byId.get(id);
|
|
95
|
+
if (!existing) {
|
|
96
|
+
const entry: Entry = { id, name, outcome, kind, cycle: this.cycle, expanded, foldable };
|
|
97
|
+
this.byId.set(id, entry);
|
|
98
|
+
this.entries.push(entry);
|
|
99
|
+
this.invalidateViews();
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (
|
|
103
|
+
existing.name === name &&
|
|
104
|
+
existing.outcome === outcome &&
|
|
105
|
+
existing.expanded === expanded &&
|
|
106
|
+
existing.foldable === foldable &&
|
|
107
|
+
existing.kind === kind
|
|
108
|
+
) return;
|
|
109
|
+
existing.name = name;
|
|
110
|
+
existing.outcome = outcome;
|
|
111
|
+
existing.expanded = expanded;
|
|
112
|
+
existing.foldable = foldable;
|
|
113
|
+
existing.kind = kind;
|
|
114
|
+
this.invalidateViews();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Observe a thinking block by its component identity and local run index. */
|
|
118
|
+
observeThinking(owner: object, runIndex: number, done: boolean, expanded: boolean): void {
|
|
119
|
+
const id = this.thinkingId(owner, runIndex);
|
|
120
|
+
this.rememberOwnerEntry(owner, id);
|
|
121
|
+
this.observe(id, "think", done ? "success" : "pending", expanded, true, "thinking");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Record prose and return its stable id. `stopReason=stop` is available on the
|
|
126
|
+
* first empty final-answer update, before OpenAI streams its first text delta;
|
|
127
|
+
* the finalized block later confirms the same fact through `phase`.
|
|
128
|
+
*/
|
|
129
|
+
observeProse(
|
|
130
|
+
owner: object,
|
|
131
|
+
contentIndex: number,
|
|
132
|
+
signal: {
|
|
133
|
+
phase?: "commentary" | "final_answer";
|
|
134
|
+
stopReason?: string;
|
|
135
|
+
streaming?: boolean;
|
|
136
|
+
timestamp?: number;
|
|
137
|
+
} = {},
|
|
138
|
+
): string {
|
|
139
|
+
const id = `prose:${this.ownerId(owner)}:${contentIndex}`;
|
|
140
|
+
const isNew = !this.byId.has(id);
|
|
141
|
+
const timestamp = signal.timestamp ?? Date.now();
|
|
142
|
+
if (!this.cycles.has(this.cycle)) {
|
|
143
|
+
this.cycles.set(this.cycle, { startedAt: timestamp, finalAnswerStarted: false });
|
|
144
|
+
}
|
|
145
|
+
this.rememberOwnerEntry(owner, id);
|
|
146
|
+
this.observe(id, "prose", "pending", false, false, "prose");
|
|
147
|
+
|
|
148
|
+
const isFinal = signal.phase === "final_answer" || signal.stopReason === "stop";
|
|
149
|
+
const cycle = this.cycles.get(this.byId.get(id)!.cycle)!;
|
|
150
|
+
if (isFinal && !cycle.finalAnswerStarted) {
|
|
151
|
+
cycle.finalAnswerStarted = true;
|
|
152
|
+
this.invalidateViews();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// session replay has no agent_start/agent_settled events. A newly observed,
|
|
156
|
+
// finalized answer is therefore the only reliable boundary between old user
|
|
157
|
+
// interactions. Advance AFTER assigning the answer so it remains the latest
|
|
158
|
+
// prose of its own cycle; stable ids prevent re-renders from advancing twice.
|
|
159
|
+
if (!this.agentActive && isNew && isFinal && !signal.streaming) {
|
|
160
|
+
cycle.settledAt = timestamp;
|
|
161
|
+
this.cycle++;
|
|
162
|
+
}
|
|
163
|
+
return id;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* `undefined` keeps native prose, `null` hides it, and a Row replaces all
|
|
168
|
+
* earlier prose. This is resolved on every render, so a newly streamed block
|
|
169
|
+
* immediately moves the summary forward without rebuilding old components.
|
|
170
|
+
*/
|
|
171
|
+
proseView(id: string, painter: Painter, now = Date.now()): Row | null | undefined {
|
|
172
|
+
const entry = this.byId.get(id);
|
|
173
|
+
const state = this.activityState(entry);
|
|
174
|
+
if (state === "show") return undefined;
|
|
175
|
+
if (state === "hide") return null;
|
|
176
|
+
return this.activitySummary(entry!, painter, now);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** True when this row owns the single folded-activity replacement. */
|
|
180
|
+
isActivitySummary(id: string): boolean {
|
|
181
|
+
return this.activityState(this.byId.get(id)) === "summary";
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Let the host component remove spacer-only output or normalize its summary gap. */
|
|
185
|
+
activityMessageView(owner: object): "normal" | "hidden" | "summary" {
|
|
186
|
+
const ids = this.ownerEntries.get(this.ownerId(owner));
|
|
187
|
+
if (!ids) return "normal";
|
|
188
|
+
const states = [...ids].map((id) => this.activityState(this.byId.get(id)));
|
|
189
|
+
if (states.includes("summary")) return "summary";
|
|
190
|
+
return states.length > 0 && states.every((state) => state === "hide") ? "hidden" : "normal";
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
thinkingId(owner: object, runIndex: number): string {
|
|
194
|
+
return `think:${this.ownerId(owner)}:${runIndex}`;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Hide a message's leading spacer only when every one of its rows is hidden. */
|
|
198
|
+
showsMessageSpacer(owner: object): boolean {
|
|
199
|
+
const entries = this.ownerEntries.get(this.ownerId(owner));
|
|
200
|
+
return !entries || [...entries].some((id) => {
|
|
201
|
+
const entry = this.byId.get(id);
|
|
202
|
+
const activity = this.activityState(entry);
|
|
203
|
+
// A summary row draws its own compact line; unlike native prose it does
|
|
204
|
+
// not need the AssistantMessageComponent's leading blank spacer.
|
|
205
|
+
if (activity !== "show") return false;
|
|
206
|
+
return entry?.kind === "prose" || this.view(id) !== "hide";
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* The single translation from grouping state to something drawable: the row's
|
|
212
|
+
* own appearance, nothing at all, or the folded run summary.
|
|
213
|
+
*
|
|
214
|
+
* Every feature routes through here, so fold semantics live in exactly one
|
|
215
|
+
* place. `base` is a thunk so an unfolded row is only painted when needed.
|
|
216
|
+
*/
|
|
217
|
+
rowFor(id: string, painter: Painter, base: () => Row): Row | null {
|
|
218
|
+
const entry = this.byId.get(id);
|
|
219
|
+
const activity = this.activityState(entry);
|
|
220
|
+
if (activity === "hide") return null;
|
|
221
|
+
if (activity === "summary") return this.activitySummary(entry!, painter);
|
|
222
|
+
|
|
223
|
+
const view = this.view(id);
|
|
224
|
+
if (view === "show") return base();
|
|
225
|
+
if (view === "hide") return null;
|
|
226
|
+
return painter.summary(view);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
view(id: string): RowView {
|
|
230
|
+
return this.views().grouped.get(id) ?? "show";
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Can this entry disappear into a run summary?
|
|
235
|
+
*
|
|
236
|
+
* A RUNNING entry folds by default: folds are recomputed on every render, so
|
|
237
|
+
* the row reappears the moment it matters, and the summary still counts it in
|
|
238
|
+
* its `running` group. `keepActiveToolsExpanded` opts out for people who want
|
|
239
|
+
* to watch a long command in place.
|
|
240
|
+
*/
|
|
241
|
+
/** Collapse every collapsible row before the latest prose into ONE activity summary. */
|
|
242
|
+
private activityState(entry: Entry | undefined): ActivityState {
|
|
243
|
+
return entry ? (this.views().activity.get(entry.id) ?? "show") : "show";
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
private activitySummary(entry: Entry, painter: Painter, now = Date.now()): Row {
|
|
247
|
+
const cycle = this.cycles.get(entry.cycle);
|
|
248
|
+
const actions = this.views().actions.get(entry.cycle);
|
|
249
|
+
if (this.config.get("activitySummary") === "tools" && actions) {
|
|
250
|
+
return painter.summary(actions);
|
|
251
|
+
}
|
|
252
|
+
const elapsed = Math.max(0, (cycle?.settledAt ?? now) - (cycle?.startedAt ?? now));
|
|
253
|
+
return painter.labeled({ label: "Worked", details: `for ${formatDuration(elapsed)}` });
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
private views(): ViewCache {
|
|
257
|
+
const key = [
|
|
258
|
+
this.version,
|
|
259
|
+
this.config.get("groupToolRuns"),
|
|
260
|
+
this.config.get("thinkingAsToolCall"),
|
|
261
|
+
this.config.get("foldIntermediateActivity"),
|
|
262
|
+
this.config.get("foldActivityOnFinalAnswer"),
|
|
263
|
+
this.config.get("keepActiveToolsExpanded"),
|
|
264
|
+
].join(":");
|
|
265
|
+
if (this.cache?.key === key) return this.cache;
|
|
266
|
+
|
|
267
|
+
const activity = new Map<string, ActivityState>();
|
|
268
|
+
const grouped = new Map<string, RowView>();
|
|
269
|
+
const actions = new Map<number, RunSummary>();
|
|
270
|
+
|
|
271
|
+
if (this.enabled) {
|
|
272
|
+
for (let first = 0; first < this.entries.length;) {
|
|
273
|
+
if (!this.foldable(this.entries[first])) {
|
|
274
|
+
first++;
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
let last = first + 1;
|
|
278
|
+
while (last < this.entries.length && this.foldable(this.entries[last])) last++;
|
|
279
|
+
if (last - first > 1) {
|
|
280
|
+
for (let index = first; index < last - 1; index++) grouped.set(this.entries[index].id, "hide");
|
|
281
|
+
grouped.set(this.entries[last - 1].id, summarize(this.entries.slice(first, last)));
|
|
282
|
+
}
|
|
283
|
+
first = last;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (this.config.get("foldIntermediateActivity")) {
|
|
288
|
+
const byCycle = new Map<number, Entry[]>();
|
|
289
|
+
for (const entry of this.entries) {
|
|
290
|
+
const entries = byCycle.get(entry.cycle) ?? [];
|
|
291
|
+
entries.push(entry);
|
|
292
|
+
byCycle.set(entry.cycle, entries);
|
|
293
|
+
}
|
|
294
|
+
for (const [cycleId, entries] of byCycle) {
|
|
295
|
+
const prose = entries.filter((entry) => entry.kind === "prose");
|
|
296
|
+
const cycle = this.cycles.get(cycleId);
|
|
297
|
+
if (
|
|
298
|
+
prose.length < 2 ||
|
|
299
|
+
(this.config.get("foldActivityOnFinalAnswer") && !cycle?.finalAnswerStarted)
|
|
300
|
+
) continue;
|
|
301
|
+
|
|
302
|
+
const beforeLatest = entries.slice(0, entries.indexOf(prose.at(-1)!));
|
|
303
|
+
const collapsible = beforeLatest.filter((entry) => this.activityFoldable(entry));
|
|
304
|
+
for (let index = 0; index < collapsible.length - 1; index++) {
|
|
305
|
+
activity.set(collapsible[index].id, "hide");
|
|
306
|
+
}
|
|
307
|
+
const tail = collapsible.at(-1);
|
|
308
|
+
if (tail) activity.set(tail.id, "summary");
|
|
309
|
+
const actionEntries = beforeLatest.filter((entry) => entry.kind !== "prose");
|
|
310
|
+
if (actionEntries.length > 0) actions.set(cycleId, summarize(actionEntries));
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return (this.cache = { key, activity, grouped, actions });
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
private invalidateViews(): void {
|
|
318
|
+
this.version++;
|
|
319
|
+
this.cache = undefined;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
private activityFoldable(entry: Entry): boolean {
|
|
323
|
+
if (entry.expanded) return false;
|
|
324
|
+
if (entry.outcome === "pending" && entry.kind !== "prose" && this.config.get("keepActiveToolsExpanded")) {
|
|
325
|
+
return false;
|
|
326
|
+
}
|
|
327
|
+
return true;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
private foldable(entry: Entry | undefined): boolean {
|
|
331
|
+
if (!entry?.foldable || entry.expanded) return false;
|
|
332
|
+
if (entry.kind === "thinking" && !this.config.get("thinkingAsToolCall")) return false;
|
|
333
|
+
if (entry.outcome === "pending" && this.config.get("keepActiveToolsExpanded")) return false;
|
|
334
|
+
return true;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
private rememberOwnerEntry(owner: object, entryId: string): void {
|
|
338
|
+
const id = this.ownerId(owner);
|
|
339
|
+
let entries = this.ownerEntries.get(id);
|
|
340
|
+
if (!entries) {
|
|
341
|
+
entries = new Set();
|
|
342
|
+
this.ownerEntries.set(id, entries);
|
|
343
|
+
}
|
|
344
|
+
entries.add(entryId);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
private ownerId(owner: object): number {
|
|
348
|
+
let id = this.ownerIds.get(owner);
|
|
349
|
+
if (id === undefined) {
|
|
350
|
+
id = this.nextOwnerId++;
|
|
351
|
+
this.ownerIds.set(owner, id);
|
|
352
|
+
}
|
|
353
|
+
return id;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Group a run's entries by outcome, each in first-seen order. */
|
|
358
|
+
export function summarize(run: Entry[]): RunSummary {
|
|
359
|
+
const named = (outcome: Outcome) =>
|
|
360
|
+
countNames(run.filter((entry) => entry.outcome === outcome).map((entry) => entry.name));
|
|
361
|
+
return { done: named("success"), failed: named("failure"), running: named("pending") };
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** Compact elapsed time: 3s, 2m 7s, 1h 4m. */
|
|
365
|
+
export function formatDuration(milliseconds: number): string {
|
|
366
|
+
const totalSeconds = Math.floor(milliseconds / 1000);
|
|
367
|
+
if (totalSeconds < 60) return `${totalSeconds}s`;
|
|
368
|
+
const minutes = Math.floor(totalSeconds / 60);
|
|
369
|
+
if (minutes < 60) return `${minutes}m ${totalSeconds % 60}s`;
|
|
370
|
+
return `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** Count names in first-seen order: read, edit, read → read ×2, edit ×1. */
|
|
374
|
+
export function countNames(names: string[]): Count[] {
|
|
375
|
+
const counts = new Map<string, number>();
|
|
376
|
+
for (const name of names) counts.set(name, (counts.get(name) ?? 0) + 1);
|
|
377
|
+
return [...counts].map(([name, count]) => ({ name, count }));
|
|
378
|
+
}
|
package/src/thinking.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FEATURE 2: thinking blocks.
|
|
3
|
+
*
|
|
4
|
+
* Collapsed — one line replacing Pi's bare "Thinking..." label, installed on
|
|
5
|
+
* `Symbol.for("pi.thinkingPreview")`:
|
|
6
|
+
*
|
|
7
|
+
* • think The user wants me to… ← while streaming
|
|
8
|
+
* ✓ think The user wants me to… ← once the message completes
|
|
9
|
+
*
|
|
10
|
+
* Expanded (Ctrl+T) — Pi's native italic Markdown, but with every token-specific
|
|
11
|
+
* color replaced by the thinking hue, on `Symbol.for("pi.thinkingMarkdownTheme")`.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { CompactLine, realTimers, type Timers } from "./components.ts";
|
|
15
|
+
import { Painter, type ThemeLike } from "./row.ts";
|
|
16
|
+
import type { RunGrouping } from "./run-grouping.ts";
|
|
17
|
+
import type { Config } from "./config.ts";
|
|
18
|
+
|
|
19
|
+
/** Core calls this with the full hidden thinking text plus a streaming flag. */
|
|
20
|
+
export type ThinkingPreview = (
|
|
21
|
+
text: string,
|
|
22
|
+
theme: ThemeLike,
|
|
23
|
+
pad: number,
|
|
24
|
+
streaming?: boolean,
|
|
25
|
+
owner?: object,
|
|
26
|
+
runIndex?: number,
|
|
27
|
+
) => CompactLine;
|
|
28
|
+
|
|
29
|
+
export type ThinkingDeps = {
|
|
30
|
+
config: Config;
|
|
31
|
+
grouping: RunGrouping;
|
|
32
|
+
timers?: Timers;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export function createThinkingPreview(deps: ThinkingDeps): ThinkingPreview {
|
|
36
|
+
const { config, grouping } = deps;
|
|
37
|
+
const timers = deps.timers ?? realTimers;
|
|
38
|
+
|
|
39
|
+
// `pad` is part of the core bridge signature but unused: the gutter already
|
|
40
|
+
// positions the row, so honoring pad too would double-indent it.
|
|
41
|
+
return (text, theme, _pad, streaming, owner, runIndex) => {
|
|
42
|
+
const line = new CompactLine(timers);
|
|
43
|
+
|
|
44
|
+
// Takes no width: thinking has a single detail field, so CompactLine's
|
|
45
|
+
// viewport-width truncation is the only budget it needs. Only the whitespace
|
|
46
|
+
// collapse matters here, to keep the preview on one physical line.
|
|
47
|
+
const paint = () => {
|
|
48
|
+
const glyphs = config.glyphs();
|
|
49
|
+
const tokens = config.tokens();
|
|
50
|
+
// Core only installs this preview when thinkingAsToolCall is enabled.
|
|
51
|
+
// Match tool-row styling; off keeps Pi's original thinking component.
|
|
52
|
+
return new Painter(theme, config).labeled({
|
|
53
|
+
glyph: streaming ? glyphs.running : glyphs.done,
|
|
54
|
+
label: "think",
|
|
55
|
+
labelColor: tokens.label,
|
|
56
|
+
details: text.replace(/\s+/g, " ").trim(),
|
|
57
|
+
});
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
if (owner && runIndex !== undefined) {
|
|
61
|
+
const id = grouping.thinkingId(owner, runIndex);
|
|
62
|
+
line.setRow(() => grouping.rowFor(id, new Painter(theme, config), paint));
|
|
63
|
+
} else {
|
|
64
|
+
line.setRow(paint);
|
|
65
|
+
}
|
|
66
|
+
return line;
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
type MarkdownTheme = Record<string, unknown>;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Keep Markdown structure and styles, but replace every token-specific color
|
|
74
|
+
* with the thinking hue, so expanded thinking reads as one block of reasoning
|
|
75
|
+
* rather than syntax-highlighted code.
|
|
76
|
+
*/
|
|
77
|
+
export function singleHueThinkingTheme(base: MarkdownTheme, theme: ThemeLike, config: Config): MarkdownTheme {
|
|
78
|
+
const hue = (text: string) => theme.fg(config.tokens().thinking, text);
|
|
79
|
+
return {
|
|
80
|
+
...base,
|
|
81
|
+
heading: hue,
|
|
82
|
+
link: hue,
|
|
83
|
+
linkUrl: hue,
|
|
84
|
+
code: hue,
|
|
85
|
+
codeBlock: hue,
|
|
86
|
+
codeBlockBorder: hue,
|
|
87
|
+
quote: hue,
|
|
88
|
+
quoteBorder: hue,
|
|
89
|
+
hr: hue,
|
|
90
|
+
listBullet: hue,
|
|
91
|
+
codeBlockIndent:
|
|
92
|
+
typeof base.codeBlockIndent === "string"
|
|
93
|
+
? // Strip existing color first: the indent arrives already themed, and
|
|
94
|
+
// nesting color codes leaves the outer one active for the whole line.
|
|
95
|
+
hue(base.codeBlockIndent.replace(/\x1b\[[0-9;]*m/g, ""))
|
|
96
|
+
: base.codeBlockIndent,
|
|
97
|
+
// cli-highlight injects its own token colors, so bypass it entirely.
|
|
98
|
+
highlightCode: (code: string) => code.split("\n").map(hue),
|
|
99
|
+
};
|
|
100
|
+
}
|