@oh-my-pi/pi-coding-agent 15.5.10 → 15.5.12
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 +34 -0
- package/dist/types/cli-commands.d.ts +19 -0
- package/dist/types/commands/install.d.ts +51 -0
- package/dist/types/discovery/index.d.ts +1 -0
- package/dist/types/discovery/omp-extension-roots.d.ts +43 -0
- package/dist/types/discovery/omp-plugins.d.ts +1 -0
- package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +14 -0
- package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +2 -0
- package/dist/types/extensibility/plugins/loader.d.ts +12 -2
- package/dist/types/index.d.ts +3 -0
- package/dist/types/modes/components/custom-editor.d.ts +3 -0
- package/dist/types/modes/ultrathink.d.ts +10 -0
- package/dist/types/session/redis-session-storage.d.ts +124 -0
- package/dist/types/session/sql-session-storage.d.ts +141 -0
- package/dist/types/tools/todo-write.d.ts +30 -0
- package/examples/sdk/12-redis-sessions.ts +54 -0
- package/examples/sdk/13-sql-sessions.ts +61 -0
- package/package.json +8 -8
- package/scripts/build-binary.ts +14 -9
- package/src/cli-commands.ts +44 -0
- package/src/cli.ts +2 -32
- package/src/commands/install.ts +107 -0
- package/src/discovery/index.ts +1 -0
- package/src/discovery/omp-extension-roots.ts +190 -0
- package/src/discovery/omp-plugins.ts +383 -0
- package/src/extensibility/legacy-pi-coding-agent-shim.ts +15 -0
- package/src/extensibility/plugins/legacy-pi-compat.ts +63 -22
- package/src/extensibility/plugins/loader.ts +43 -18
- package/src/index.ts +3 -0
- package/src/internal-urls/docs-index.generated.ts +2 -2
- package/src/main.ts +12 -0
- package/src/memories/index.ts +8 -3
- package/src/modes/components/custom-editor.ts +3 -0
- package/src/modes/interactive-mode.ts +243 -12
- package/src/modes/ultrathink.ts +79 -0
- package/src/prompts/system/ultrathink-notice.md +3 -0
- package/src/session/agent-session.ts +28 -0
- package/src/session/redis-session-storage.ts +481 -0
- package/src/session/sql-session-storage.ts +565 -0
- package/src/tools/read.ts +23 -6
- package/src/tools/todo-write.ts +64 -0
- package/src/tools/write.ts +40 -6
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Editor, type KeyId, matchesKey, parseKittySequence } from "@oh-my-pi/pi-tui";
|
|
2
2
|
import type { AppKeybinding } from "../../config/keybindings";
|
|
3
|
+
import { highlightUltrathink } from "../ultrathink";
|
|
3
4
|
|
|
4
5
|
type ConfigurableEditorAction = Extract<
|
|
5
6
|
AppKeybinding,
|
|
@@ -44,6 +45,8 @@ const DEFAULT_ACTION_KEYS: Record<ConfigurableEditorAction, KeyId[]> = {
|
|
|
44
45
|
* Custom editor that handles configurable app-level shortcuts for coding-agent.
|
|
45
46
|
*/
|
|
46
47
|
export class CustomEditor extends Editor {
|
|
48
|
+
/** Rainbow-highlight the "ultrathink" keyword as the user types it. */
|
|
49
|
+
decorateText = highlightUltrathink;
|
|
47
50
|
onEscape?: () => void;
|
|
48
51
|
shouldBypassAutocompleteOnEscape?: () => boolean;
|
|
49
52
|
onClear?: () => void;
|
|
@@ -67,7 +67,7 @@ import type { LspStartupServerInfo } from "../tools";
|
|
|
67
67
|
import { normalizeLocalScheme } from "../tools/path-utils";
|
|
68
68
|
import { setAutoQaConsentHandler } from "../tools/report-tool-issue";
|
|
69
69
|
import { type ResolveToolDetails, runResolveInvocation } from "../tools/resolve";
|
|
70
|
-
import { formatPhaseDisplayName } from "../tools/todo-write";
|
|
70
|
+
import { formatPhaseDisplayName, selectStickyTodoWindow, todoMatchesAnyDescription } from "../tools/todo-write";
|
|
71
71
|
import { ToolError } from "../tools/tool-errors";
|
|
72
72
|
import type { EventBus } from "../utils/event-bus";
|
|
73
73
|
import { getEditorCommand, openInEditor } from "../utils/external-editor";
|
|
@@ -324,6 +324,10 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
324
324
|
#eventBus?: EventBus;
|
|
325
325
|
#eventBusUnsubscribers: Array<() => void> = [];
|
|
326
326
|
#welcomeComponent?: WelcomeComponent;
|
|
327
|
+
#todoSpinnerInterval?: NodeJS.Timeout;
|
|
328
|
+
#todoSpinnerFrame = 0;
|
|
329
|
+
#todoClosingTimeout?: NodeJS.Timeout;
|
|
330
|
+
#todoClosingState: "idle" | "playing" | "done" = "idle";
|
|
327
331
|
|
|
328
332
|
constructor(
|
|
329
333
|
session: AgentSession,
|
|
@@ -529,6 +533,12 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
529
533
|
this.#observerRegistry.setMainSession(this.sessionManager.getSessionFile() ?? undefined);
|
|
530
534
|
this.#observerRegistry.onChange(() => {
|
|
531
535
|
this.statusLine.setSubagentCount(this.#observerRegistry.getActiveSubagentCount());
|
|
536
|
+
// Auto-checkmark todos whose matching subagent just succeeded, then
|
|
537
|
+
// re-render so the running override (animated row when a subagent
|
|
538
|
+
// is doing the work for a still-pending todo) updates as subagents
|
|
539
|
+
// start, finish, or fail. Also handles spinner start/stop.
|
|
540
|
+
this.#reconcileTodosWithSubagents();
|
|
541
|
+
this.#renderTodoList();
|
|
532
542
|
this.ui.requestRender();
|
|
533
543
|
});
|
|
534
544
|
|
|
@@ -839,6 +849,7 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
839
849
|
this.#pendingSubmissionDispose = undefined;
|
|
840
850
|
}
|
|
841
851
|
this.editor.setText("");
|
|
852
|
+
this.ui.refreshNativeScrollbackIfDirty();
|
|
842
853
|
this.ensureLoadingAnimation();
|
|
843
854
|
this.ui.requestRender();
|
|
844
855
|
return submission;
|
|
@@ -949,21 +960,101 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
949
960
|
this.renderSessionContext(context);
|
|
950
961
|
}
|
|
951
962
|
|
|
952
|
-
#formatTodoLine(todo: TodoItem, prefix: string): string {
|
|
963
|
+
#formatTodoLine(todo: TodoItem, prefix: string, matched: boolean, spinnerOn: boolean): string {
|
|
953
964
|
const checkbox = theme.checkbox;
|
|
954
965
|
const marker = formatHudNoteMarker(todo.notes?.length ?? 0);
|
|
966
|
+
const frames = theme.spinnerFrames;
|
|
967
|
+
// When the spinner is ticking, use the current animated frame; otherwise
|
|
968
|
+
// fall back to the static "running" glyph so in_progress rows still look
|
|
969
|
+
// distinct from pending rows.
|
|
970
|
+
const runningGlyph =
|
|
971
|
+
spinnerOn && frames.length > 0
|
|
972
|
+
? (frames[this.#todoSpinnerFrame % frames.length] ?? theme.status.running)
|
|
973
|
+
: theme.status.running;
|
|
955
974
|
switch (todo.status) {
|
|
956
975
|
case "completed":
|
|
957
|
-
return
|
|
976
|
+
return (
|
|
977
|
+
theme.fg("success", `${prefix}${theme.status.success} ${chalk.strikethrough(todo.content)}`) + marker
|
|
978
|
+
);
|
|
958
979
|
case "in_progress":
|
|
959
|
-
return theme.fg("accent", `${prefix}${
|
|
980
|
+
return theme.fg("accent", `${prefix}${runningGlyph} ${todo.content}`) + marker;
|
|
960
981
|
case "abandoned":
|
|
961
982
|
return theme.fg("error", `${prefix}${checkbox.unchecked} ${chalk.strikethrough(todo.content)}`) + marker;
|
|
962
983
|
default:
|
|
984
|
+
if (matched) {
|
|
985
|
+
return theme.fg("accent", `${prefix}${runningGlyph} ${todo.content}`) + marker;
|
|
986
|
+
}
|
|
963
987
|
return theme.fg("dim", `${prefix}${checkbox.unchecked} ${todo.content}`) + marker;
|
|
964
988
|
}
|
|
965
989
|
}
|
|
966
990
|
|
|
991
|
+
#getActiveSubagentDescriptions(): string[] {
|
|
992
|
+
const out: string[] = [];
|
|
993
|
+
for (const session of this.#observerRegistry.getSessions()) {
|
|
994
|
+
if (session.kind !== "subagent") continue;
|
|
995
|
+
if (session.status !== "active") continue;
|
|
996
|
+
const candidate =
|
|
997
|
+
session.description?.trim() || session.progress?.description?.trim() || session.label?.trim();
|
|
998
|
+
if (candidate) out.push(candidate);
|
|
999
|
+
}
|
|
1000
|
+
return out;
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
/**
|
|
1004
|
+
* Auto-complete any pending/in_progress todo whose content matches a
|
|
1005
|
+
* subagent that has finished successfully. Fires on every observer
|
|
1006
|
+
* `onChange` so the visual state stays in sync with subagent lifecycle
|
|
1007
|
+
* without requiring the agent to issue a follow-up `todo_write`. Failed
|
|
1008
|
+
* and aborted subagents are intentionally NOT auto-completed — those
|
|
1009
|
+
* stay open so the user (or the next agent turn) can decide what to do.
|
|
1010
|
+
*
|
|
1011
|
+
* Idempotent: only flips open tasks, never re-touches completed ones.
|
|
1012
|
+
*/
|
|
1013
|
+
#reconcileTodosWithSubagents(): void {
|
|
1014
|
+
const completedDescs: string[] = [];
|
|
1015
|
+
for (const session of this.#observerRegistry.getSessions()) {
|
|
1016
|
+
if (session.kind !== "subagent") continue;
|
|
1017
|
+
if (session.status !== "completed") continue;
|
|
1018
|
+
const candidate =
|
|
1019
|
+
session.description?.trim() || session.progress?.description?.trim() || session.label?.trim();
|
|
1020
|
+
if (candidate) completedDescs.push(candidate);
|
|
1021
|
+
}
|
|
1022
|
+
if (completedDescs.length === 0) return;
|
|
1023
|
+
|
|
1024
|
+
let mutated = false;
|
|
1025
|
+
const next: TodoPhase[] = this.todoPhases.map(phase => ({
|
|
1026
|
+
name: phase.name,
|
|
1027
|
+
tasks: phase.tasks.map(task => {
|
|
1028
|
+
if (task.status !== "pending" && task.status !== "in_progress") return task;
|
|
1029
|
+
if (!todoMatchesAnyDescription(task.content, completedDescs)) return task;
|
|
1030
|
+
mutated = true;
|
|
1031
|
+
return { ...task, status: "completed" as const };
|
|
1032
|
+
}),
|
|
1033
|
+
}));
|
|
1034
|
+
if (!mutated) return;
|
|
1035
|
+
this.todoPhases = next;
|
|
1036
|
+
this.session.setTodoPhases(next);
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
#updateTodoSpinnerAnimation(needSpinner: boolean): void {
|
|
1040
|
+
if (needSpinner) {
|
|
1041
|
+
if (this.#todoSpinnerInterval) return;
|
|
1042
|
+
this.#todoSpinnerInterval = setInterval(() => {
|
|
1043
|
+
const frames = theme.spinnerFrames;
|
|
1044
|
+
if (frames.length === 0) return;
|
|
1045
|
+
this.#todoSpinnerFrame = (this.#todoSpinnerFrame + 1) % frames.length;
|
|
1046
|
+
// Rebuild the todo container so the new frame appears, then schedule
|
|
1047
|
+
// a paint. The renderer self-stops the interval once no row needs it.
|
|
1048
|
+
this.#renderTodoList();
|
|
1049
|
+
this.ui.requestRender();
|
|
1050
|
+
}, 80);
|
|
1051
|
+
} else if (this.#todoSpinnerInterval) {
|
|
1052
|
+
clearInterval(this.#todoSpinnerInterval);
|
|
1053
|
+
this.#todoSpinnerInterval = undefined;
|
|
1054
|
+
this.#todoSpinnerFrame = 0;
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
|
|
967
1058
|
#getActivePhase(phases: TodoPhase[]): TodoPhase | undefined {
|
|
968
1059
|
const nonEmpty = phases.filter(phase => phase.tasks.length > 0);
|
|
969
1060
|
const active = nonEmpty.find(phase =>
|
|
@@ -976,44 +1067,183 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
976
1067
|
this.todoContainer.clear();
|
|
977
1068
|
const phases = this.todoPhases.filter(phase => phase.tasks.length > 0);
|
|
978
1069
|
if (phases.length === 0) {
|
|
1070
|
+
this.#updateTodoSpinnerAnimation(false);
|
|
1071
|
+
this.#stopTodoClosingAnimation();
|
|
1072
|
+
this.#todoClosingState = "idle";
|
|
979
1073
|
return;
|
|
980
1074
|
}
|
|
981
1075
|
|
|
1076
|
+
// When every visible task is completed or abandoned, fold the panel
|
|
1077
|
+
// away with a brief celebratory animation (see
|
|
1078
|
+
// #startTodoClosingAnimation). State machine guards against replaying
|
|
1079
|
+
// on every re-render once the animation has finished.
|
|
1080
|
+
const allClosed = phases.every(phase =>
|
|
1081
|
+
phase.tasks.every(t => t.status === "completed" || t.status === "abandoned"),
|
|
1082
|
+
);
|
|
1083
|
+
if (allClosed) {
|
|
1084
|
+
this.#updateTodoSpinnerAnimation(false);
|
|
1085
|
+
if (this.#todoClosingState === "done") return;
|
|
1086
|
+
if (this.#todoClosingState === "idle") this.#startTodoClosingAnimation(phases);
|
|
1087
|
+
return;
|
|
1088
|
+
}
|
|
1089
|
+
// Any open task here means the close animation is no longer applicable.
|
|
1090
|
+
this.#stopTodoClosingAnimation();
|
|
1091
|
+
this.#todoClosingState = "idle";
|
|
1092
|
+
|
|
982
1093
|
const indent = " ";
|
|
983
1094
|
const hook = theme.tree.hook;
|
|
984
1095
|
const lines = ["", indent + theme.bold(theme.fg("accent", "Todos"))];
|
|
985
1096
|
|
|
1097
|
+
const activeDescs = this.#getActiveSubagentDescriptions();
|
|
1098
|
+
// Cache matcher results so we don't re-scan the description list per row
|
|
1099
|
+
// twice (once for the spinner decision, once for the render).
|
|
1100
|
+
const matchedSet = new Set<TodoItem>();
|
|
1101
|
+
const isMatched = (todo: TodoItem): boolean => {
|
|
1102
|
+
if (activeDescs.length === 0) return false;
|
|
1103
|
+
if (matchedSet.has(todo)) return true;
|
|
1104
|
+
if (todoMatchesAnyDescription(todo.content, activeDescs)) {
|
|
1105
|
+
matchedSet.add(todo);
|
|
1106
|
+
return true;
|
|
1107
|
+
}
|
|
1108
|
+
return false;
|
|
1109
|
+
};
|
|
1110
|
+
|
|
1111
|
+
// The cube animates whenever any visible open todo is "live":
|
|
1112
|
+
// (a) status is in_progress (the agent itself is working it), or
|
|
1113
|
+
// (b) a still-pending todo has a matching in-flight subagent doing
|
|
1114
|
+
// the work for it. The renderer self-stops the interval once no row
|
|
1115
|
+
// qualifies, so an orphan in_progress row at end-of-session keeps
|
|
1116
|
+
// ticking — that's the intentional "this todo is still open" signal.
|
|
1117
|
+
let needsSpinner = false;
|
|
1118
|
+
const considerForSpinner = (todo: TodoItem): void => {
|
|
1119
|
+
if (todo.status === "in_progress") {
|
|
1120
|
+
needsSpinner = true;
|
|
1121
|
+
return;
|
|
1122
|
+
}
|
|
1123
|
+
if (todo.status !== "pending") return;
|
|
1124
|
+
if (isMatched(todo)) needsSpinner = true;
|
|
1125
|
+
};
|
|
1126
|
+
|
|
986
1127
|
if (!this.todoExpanded) {
|
|
987
1128
|
const activeIdx = phases.indexOf(this.#getActivePhase(phases) ?? phases[0]);
|
|
988
1129
|
const activePhase = phases[activeIdx];
|
|
989
|
-
if (!activePhase)
|
|
1130
|
+
if (!activePhase) {
|
|
1131
|
+
this.#updateTodoSpinnerAnimation(false);
|
|
1132
|
+
return;
|
|
1133
|
+
}
|
|
1134
|
+
const { visible, hiddenOpenCount } = selectStickyTodoWindow(activePhase.tasks, 5);
|
|
1135
|
+
for (const todo of visible) considerForSpinner(todo);
|
|
1136
|
+
this.#updateTodoSpinnerAnimation(needsSpinner);
|
|
1137
|
+
|
|
990
1138
|
lines.push(
|
|
991
1139
|
`${indent}${theme.fg("accent", `${hook} ${formatPhaseDisplayName(activePhase.name, activeIdx + 1)}`)}`,
|
|
992
1140
|
);
|
|
993
|
-
|
|
994
|
-
visibleTasks.forEach((todo, index) => {
|
|
1141
|
+
visible.forEach((todo, index) => {
|
|
995
1142
|
const prefix = `${indent}${index === 0 ? hook : " "} `;
|
|
996
|
-
lines.push(this.#formatTodoLine(todo, prefix));
|
|
1143
|
+
lines.push(this.#formatTodoLine(todo, prefix, matchedSet.has(todo), needsSpinner));
|
|
997
1144
|
});
|
|
998
|
-
if (
|
|
999
|
-
|
|
1000
|
-
lines.push(theme.fg("muted", `${indent} ${hook} +${remaining} more`));
|
|
1145
|
+
if (hiddenOpenCount > 0) {
|
|
1146
|
+
lines.push(theme.fg("muted", `${indent} ${hook} +${hiddenOpenCount} more`));
|
|
1001
1147
|
}
|
|
1002
1148
|
this.todoContainer.addChild(new Text(lines.join("\n"), 1, 0));
|
|
1003
1149
|
return;
|
|
1004
1150
|
}
|
|
1005
1151
|
|
|
1152
|
+
for (const phase of phases) for (const todo of phase.tasks) considerForSpinner(todo);
|
|
1153
|
+
this.#updateTodoSpinnerAnimation(needsSpinner);
|
|
1154
|
+
|
|
1006
1155
|
phases.forEach((phase, phaseIndex) => {
|
|
1007
1156
|
lines.push(`${indent}${theme.fg("accent", `${hook} ${formatPhaseDisplayName(phase.name, phaseIndex + 1)}`)}`);
|
|
1008
1157
|
phase.tasks.forEach((todo, index) => {
|
|
1009
1158
|
const prefix = `${indent}${index === 0 ? hook : " "} `;
|
|
1010
|
-
lines.push(this.#formatTodoLine(todo, prefix));
|
|
1159
|
+
lines.push(this.#formatTodoLine(todo, prefix, matchedSet.has(todo), needsSpinner));
|
|
1011
1160
|
});
|
|
1012
1161
|
});
|
|
1013
1162
|
|
|
1014
1163
|
this.todoContainer.addChild(new Text(lines.join("\n"), 1, 0));
|
|
1015
1164
|
}
|
|
1016
1165
|
|
|
1166
|
+
/**
|
|
1167
|
+
* Play a short "all done" close animation: a celebratory bright frame,
|
|
1168
|
+
* a brief dim transition, then a row-by-row vertical collapse until the
|
|
1169
|
+
* panel is empty. Triggered from #renderTodoList exactly once per
|
|
1170
|
+
* open-to-all-closed transition; #todoClosingState gates re-entry.
|
|
1171
|
+
*
|
|
1172
|
+
* While playing, the animator owns the panel container; #renderTodoList
|
|
1173
|
+
* returns early. Subsequent renders with state === "done" keep the
|
|
1174
|
+
* panel hidden until a fresh open task flips state back to "idle".
|
|
1175
|
+
*/
|
|
1176
|
+
#startTodoClosingAnimation(phases: TodoPhase[]): void {
|
|
1177
|
+
this.#stopTodoClosingAnimation();
|
|
1178
|
+
this.#todoClosingState = "playing";
|
|
1179
|
+
|
|
1180
|
+
const indent = " ";
|
|
1181
|
+
const hook = theme.tree.hook;
|
|
1182
|
+
const snapshot: string[] = ["", `${indent}Todos ${theme.status.success}`];
|
|
1183
|
+
for (let i = 0; i < phases.length; i++) {
|
|
1184
|
+
const phase = phases[i];
|
|
1185
|
+
snapshot.push(`${indent}${hook} ${formatPhaseDisplayName(phase.name, i + 1)}`);
|
|
1186
|
+
for (let j = 0; j < phase.tasks.length; j++) {
|
|
1187
|
+
const task = phase.tasks[j];
|
|
1188
|
+
const mark = task.status === "abandoned" ? theme.status.aborted : theme.status.success;
|
|
1189
|
+
const prefix = `${indent}${j === 0 ? hook : " "} `;
|
|
1190
|
+
snapshot.push(`${prefix}${mark} ${task.content}`);
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
// Frame schedule (tint, drop-from-bottom, hold-ms). Frame 0 holds long
|
|
1195
|
+
// enough for the user to actually read the final checkmarks before the
|
|
1196
|
+
// fade starts; later frames fade and progressively drop rows from the
|
|
1197
|
+
// bottom for the collapse effect. Total runtime ≈ 1.4s.
|
|
1198
|
+
const frames = [
|
|
1199
|
+
{ tint: "success" as const, drop: 0, holdMs: 900 },
|
|
1200
|
+
{ tint: "success" as const, drop: 0, holdMs: 150 },
|
|
1201
|
+
{ tint: "muted" as const, drop: 1, holdMs: 90 },
|
|
1202
|
+
{ tint: "muted" as const, drop: 2, holdMs: 90 },
|
|
1203
|
+
{ tint: "dim" as const, drop: 3, holdMs: 80 },
|
|
1204
|
+
{ tint: "dim" as const, drop: 4, holdMs: 80 },
|
|
1205
|
+
];
|
|
1206
|
+
|
|
1207
|
+
let frameIdx = 0;
|
|
1208
|
+
const tick = (): void => {
|
|
1209
|
+
if (this.#todoClosingState !== "playing") return;
|
|
1210
|
+
if (frameIdx >= frames.length) {
|
|
1211
|
+
this.todoContainer.clear();
|
|
1212
|
+
this.#stopTodoClosingAnimation();
|
|
1213
|
+
this.#todoClosingState = "done";
|
|
1214
|
+
this.ui.requestRender();
|
|
1215
|
+
return;
|
|
1216
|
+
}
|
|
1217
|
+
const { tint, drop, holdMs } = frames[frameIdx];
|
|
1218
|
+
const visibleCount = Math.max(0, snapshot.length - drop);
|
|
1219
|
+
this.todoContainer.clear();
|
|
1220
|
+
if (visibleCount > 0) {
|
|
1221
|
+
const visible = snapshot.slice(0, visibleCount);
|
|
1222
|
+
const painted = visible.map((line, idx) => {
|
|
1223
|
+
if (idx === 1) {
|
|
1224
|
+
// Header row gets a bold flourish on the opening tick.
|
|
1225
|
+
const colored = theme.fg(tint, line);
|
|
1226
|
+
return frameIdx === 0 ? theme.bold(colored) : colored;
|
|
1227
|
+
}
|
|
1228
|
+
return theme.fg(tint, line);
|
|
1229
|
+
});
|
|
1230
|
+
this.todoContainer.addChild(new Text(painted.join("\n"), 1, 0));
|
|
1231
|
+
}
|
|
1232
|
+
this.ui.requestRender();
|
|
1233
|
+
frameIdx++;
|
|
1234
|
+
this.#todoClosingTimeout = setTimeout(tick, holdMs);
|
|
1235
|
+
};
|
|
1236
|
+
|
|
1237
|
+
tick();
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
#stopTodoClosingAnimation(): void {
|
|
1241
|
+
if (this.#todoClosingTimeout) {
|
|
1242
|
+
clearTimeout(this.#todoClosingTimeout);
|
|
1243
|
+
this.#todoClosingTimeout = undefined;
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1017
1247
|
async #loadTodoList(): Promise<void> {
|
|
1018
1248
|
this.todoPhases = this.session.getTodoPhases();
|
|
1019
1249
|
this.#renderTodoList();
|
|
@@ -2035,6 +2265,7 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
2035
2265
|
this.loadingAnimation = undefined;
|
|
2036
2266
|
}
|
|
2037
2267
|
this.#cleanupMicAnimation();
|
|
2268
|
+
this.#updateTodoSpinnerAnimation(false);
|
|
2038
2269
|
this.#cancelGoalContinuation();
|
|
2039
2270
|
if (this.#sttController) {
|
|
2040
2271
|
this.#sttController.dispose();
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import ultrathinkNotice from "../prompts/system/ultrathink-notice.md" with { type: "text" };
|
|
2
|
+
import { theme } from "./theme/theme";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* "ultrathink" keyword support, mirroring Claude Code's affordance.
|
|
6
|
+
*
|
|
7
|
+
* Typing the standalone word in the input editor paints it with a rainbow
|
|
8
|
+
* gradient ({@link highlightUltrathink}); submitting a message that mentions it
|
|
9
|
+
* appends a hidden {@link ULTRATHINK_NOTICE} nudging the model toward careful
|
|
10
|
+
* multi-step reasoning. Matching is word-bounded and case-insensitive, so
|
|
11
|
+
* "ultrathinking"/"ultrathinks" never trigger either behavior.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
// Cheap, stateless presence probe used to skip the boundary regex on most lines.
|
|
15
|
+
const ULTRATHINK_PROBE = /ultrathink/i;
|
|
16
|
+
// Detection: standalone keyword, any case. Non-global so `.test` stays stateless.
|
|
17
|
+
const ULTRATHINK_WORD = /\bultrathink\b/i;
|
|
18
|
+
// Highlight: global so `.replace` walks every occurrence.
|
|
19
|
+
const ULTRATHINK_HIGHLIGHT = /\bultrathink\b/gi;
|
|
20
|
+
|
|
21
|
+
/** Hidden system notice appended after a user message that mentions "ultrathink". */
|
|
22
|
+
export const ULTRATHINK_NOTICE: string = ultrathinkNotice.trim();
|
|
23
|
+
|
|
24
|
+
/** Whether `text` contains the standalone keyword "ultrathink" (any case). */
|
|
25
|
+
export function containsUltrathink(text: string): boolean {
|
|
26
|
+
return ULTRATHINK_WORD.test(text);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const FG_RESET = "\x1b[39m";
|
|
30
|
+
// Hue stops swept across the visible spectrum. More stops than the keyword has
|
|
31
|
+
// letters so the gradient resolves smoothly regardless of casing/match length.
|
|
32
|
+
const RAINBOW_STOPS = 14;
|
|
33
|
+
|
|
34
|
+
let cachedMode: string | undefined;
|
|
35
|
+
let cachedPalette: readonly string[] | undefined;
|
|
36
|
+
|
|
37
|
+
/** Rainbow foreground escapes for the active color mode, compiled once per mode. */
|
|
38
|
+
function rainbowPalette(): readonly string[] {
|
|
39
|
+
const mode = theme.getColorMode();
|
|
40
|
+
if (cachedPalette && cachedMode === mode) return cachedPalette;
|
|
41
|
+
const format = mode === "truecolor" ? "ansi-16m" : "ansi-256";
|
|
42
|
+
const palette: string[] = [];
|
|
43
|
+
for (let i = 0; i < RAINBOW_STOPS; i++) {
|
|
44
|
+
// Sweep red→violet (0..330°), stopping short of the wrap back to red.
|
|
45
|
+
const hue = Math.round((i / RAINBOW_STOPS) * 330);
|
|
46
|
+
palette.push(Bun.color(`hsl(${hue}, 90%, 62%)`, format) ?? "");
|
|
47
|
+
}
|
|
48
|
+
cachedMode = mode;
|
|
49
|
+
cachedPalette = palette;
|
|
50
|
+
return palette;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Paint each character of `word` with the next rainbow stop, resetting fg after. */
|
|
54
|
+
function rainbow(word: string): string {
|
|
55
|
+
const palette = rainbowPalette();
|
|
56
|
+
const n = word.length;
|
|
57
|
+
let out = "";
|
|
58
|
+
let prev = "";
|
|
59
|
+
for (let i = 0; i < n; i++) {
|
|
60
|
+
const color = palette[Math.floor((i / n) * palette.length)] ?? palette[0] ?? "";
|
|
61
|
+
// Coalesce consecutive characters that resolve to the same stop.
|
|
62
|
+
if (color !== prev) {
|
|
63
|
+
out += color;
|
|
64
|
+
prev = color;
|
|
65
|
+
}
|
|
66
|
+
out += word[i];
|
|
67
|
+
}
|
|
68
|
+
return `${out}${FG_RESET}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Rainbow-highlight every standalone "ultrathink" in `text` for editor display.
|
|
73
|
+
* Adds only zero-width SGR escapes — the visible width is unchanged — and returns
|
|
74
|
+
* the input untouched when the keyword is absent.
|
|
75
|
+
*/
|
|
76
|
+
export function highlightUltrathink(text: string): string {
|
|
77
|
+
if (!ULTRATHINK_PROBE.test(text)) return text;
|
|
78
|
+
return text.replace(ULTRATHINK_HIGHLIGHT, rainbow);
|
|
79
|
+
}
|
|
@@ -148,6 +148,7 @@ import type { HindsightSessionState } from "../hindsight/state";
|
|
|
148
148
|
import { type LocalProtocolOptions, resolveLocalUrlToPath } from "../internal-urls";
|
|
149
149
|
import { resolveMemoryBackend } from "../memory-backend";
|
|
150
150
|
import { getCurrentThemeName, theme } from "../modes/theme/theme";
|
|
151
|
+
import { containsUltrathink, ULTRATHINK_NOTICE } from "../modes/ultrathink";
|
|
151
152
|
import type { PlanModeState } from "../plan-mode/state";
|
|
152
153
|
import autoContinuePrompt from "../prompts/system/auto-continue.md" with { type: "text" };
|
|
153
154
|
import eagerTodoPrompt from "../prompts/system/eager-todo.md" with { type: "text" };
|
|
@@ -3997,6 +3998,21 @@ export class AgentSession {
|
|
|
3997
3998
|
// Expand file-based prompt templates if requested
|
|
3998
3999
|
const expandedText = expandPromptTemplates ? expandPromptTemplate(text, [...this.#promptTemplates]) : text;
|
|
3999
4000
|
|
|
4001
|
+
// "ultrathink" keyword: nudge the model toward careful multi-step reasoning by
|
|
4002
|
+
// appending a hidden notice after the user's message. User-authored prompts only —
|
|
4003
|
+
// synthetic/agent-initiated turns never trigger it.
|
|
4004
|
+
const ultrathinkNotice: CustomMessage | undefined =
|
|
4005
|
+
!options?.synthetic && containsUltrathink(expandedText)
|
|
4006
|
+
? {
|
|
4007
|
+
role: "custom",
|
|
4008
|
+
customType: "ultrathink-notice",
|
|
4009
|
+
content: ULTRATHINK_NOTICE,
|
|
4010
|
+
display: false,
|
|
4011
|
+
attribution: "user",
|
|
4012
|
+
timestamp: Date.now(),
|
|
4013
|
+
}
|
|
4014
|
+
: undefined;
|
|
4015
|
+
|
|
4000
4016
|
// If streaming, queue via steer() or followUp() based on option
|
|
4001
4017
|
if (this.isStreaming) {
|
|
4002
4018
|
if (!options?.streamingBehavior) {
|
|
@@ -4007,6 +4023,10 @@ export class AgentSession {
|
|
|
4007
4023
|
} else {
|
|
4008
4024
|
await this.#queueSteer(expandedText, options?.images);
|
|
4009
4025
|
}
|
|
4026
|
+
// Steer/follow-up the ultrathink notice alongside the queued user message.
|
|
4027
|
+
if (ultrathinkNotice) {
|
|
4028
|
+
await this.sendCustomMessage(ultrathinkNotice, { deliverAs: options.streamingBehavior });
|
|
4029
|
+
}
|
|
4010
4030
|
return;
|
|
4011
4031
|
}
|
|
4012
4032
|
|
|
@@ -4035,6 +4055,7 @@ export class AgentSession {
|
|
|
4035
4055
|
await this.#promptWithMessage(message, expandedText, {
|
|
4036
4056
|
...options,
|
|
4037
4057
|
prependMessages: eagerTodoPrelude ? [eagerTodoPrelude.message] : undefined,
|
|
4058
|
+
appendMessages: ultrathinkNotice ? [ultrathinkNotice] : undefined,
|
|
4038
4059
|
});
|
|
4039
4060
|
} finally {
|
|
4040
4061
|
// Clean up residual eager-todo directive if the prompt never consumed it
|
|
@@ -4084,6 +4105,7 @@ export class AgentSession {
|
|
|
4084
4105
|
expandedText: string,
|
|
4085
4106
|
options?: Pick<PromptOptions, "toolChoice" | "images" | "skipCompactionCheck"> & {
|
|
4086
4107
|
prependMessages?: AgentMessage[];
|
|
4108
|
+
appendMessages?: AgentMessage[];
|
|
4087
4109
|
skipPostPromptRecoveryWait?: boolean;
|
|
4088
4110
|
},
|
|
4089
4111
|
): Promise<void> {
|
|
@@ -4147,6 +4169,12 @@ export class AgentSession {
|
|
|
4147
4169
|
|
|
4148
4170
|
messages.push(message);
|
|
4149
4171
|
|
|
4172
|
+
// Inject the ultrathink notice (and any other per-turn appends) right after the
|
|
4173
|
+
// user message so the model reads it as part of the same turn.
|
|
4174
|
+
if (options?.appendMessages) {
|
|
4175
|
+
messages.push(...options.appendMessages);
|
|
4176
|
+
}
|
|
4177
|
+
|
|
4150
4178
|
// Early bail-out: if a newer abort/prompt cycle started during setup,
|
|
4151
4179
|
// return before mutating shared state (nextTurn messages, system prompt).
|
|
4152
4180
|
if (this.#promptGeneration !== generation) {
|