botholomew 0.15.3 → 0.15.5
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/package.json +1 -1
- package/src/chat/agent.ts +18 -5
- package/src/commands/chat.ts +2 -1
- package/src/context/store.ts +4 -22
- package/src/fs/patches.ts +39 -0
- package/src/schedules/store.ts +11 -5
- package/src/tasks/store.ts +6 -6
- package/src/tools/file/edit.ts +2 -11
- package/src/tools/prompt/edit.ts +148 -0
- package/src/tools/prompt/read.ts +70 -0
- package/src/tools/registry.ts +9 -4
- package/src/tools/schedule/create.ts +3 -1
- package/src/tools/schedule/edit.ts +126 -0
- package/src/tools/skill/edit.ts +3 -33
- package/src/tools/task/create.ts +3 -1
- package/src/tools/task/delete.ts +3 -1
- package/src/tools/task/edit.ts +214 -0
- package/src/tools/task/update.ts +3 -1
- package/src/tools/tool.ts +7 -0
- package/src/tui/App.tsx +67 -7
- package/src/tui/components/ContextPanel.tsx +3 -2
- package/src/tui/components/HelpPanel.tsx +9 -7
- package/src/tui/components/SchedulePanel.tsx +2 -2
- package/src/tui/components/TaskPanel.tsx +2 -2
- package/src/tui/components/ThreadPanel.tsx +2 -2
- package/src/tui/components/ToolCall.tsx +14 -0
- package/src/tui/components/WorkerPanel.tsx +15 -4
- package/src/tools/context/update-beliefs.ts +0 -64
- package/src/tools/context/update-goals.ts +0 -64
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import {
|
|
3
|
+
atomicWriteIfUnchanged,
|
|
4
|
+
MtimeConflictError,
|
|
5
|
+
readWithMtime,
|
|
6
|
+
} from "../../fs/atomic.ts";
|
|
7
|
+
import { applyLinePatches, LinePatchSchema } from "../../fs/patches.ts";
|
|
8
|
+
import type { ScheduleFrontmatter } from "../../schedules/schema.ts";
|
|
9
|
+
import {
|
|
10
|
+
parseScheduleFile,
|
|
11
|
+
scheduleFilePath,
|
|
12
|
+
serializeSchedule,
|
|
13
|
+
} from "../../schedules/store.ts";
|
|
14
|
+
import type { ToolDefinition } from "../tool.ts";
|
|
15
|
+
|
|
16
|
+
const inputSchema = z.object({
|
|
17
|
+
id: z.string().describe("Schedule id"),
|
|
18
|
+
patches: z.array(LinePatchSchema).describe("Patches to apply"),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const outputSchema = z.object({
|
|
22
|
+
id: z.string(),
|
|
23
|
+
path: z.string().nullable(),
|
|
24
|
+
applied: z.number(),
|
|
25
|
+
content: z.string(),
|
|
26
|
+
is_error: z.boolean(),
|
|
27
|
+
error_type: z.string().optional(),
|
|
28
|
+
message: z.string().optional(),
|
|
29
|
+
next_action_hint: z.string().optional(),
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
export const scheduleEditTool = {
|
|
33
|
+
name: "schedule_edit",
|
|
34
|
+
description:
|
|
35
|
+
"[[ bash equivalent command: patch ]] Apply git-style line-range patches to a schedule file. Operates on the whole file (frontmatter + body). Patches whose result fails frontmatter validation are rejected without writing. Re-serializes to canonicalize YAML and bump updated_at. Use schedule_list to find ids.",
|
|
36
|
+
group: "schedule",
|
|
37
|
+
inputSchema,
|
|
38
|
+
outputSchema,
|
|
39
|
+
execute: async (input, ctx) => {
|
|
40
|
+
const filePath = scheduleFilePath(ctx.projectDir, input.id);
|
|
41
|
+
const file = await readWithMtime(filePath);
|
|
42
|
+
if (!file) {
|
|
43
|
+
return {
|
|
44
|
+
id: input.id,
|
|
45
|
+
path: null,
|
|
46
|
+
applied: 0,
|
|
47
|
+
content: "",
|
|
48
|
+
is_error: true,
|
|
49
|
+
error_type: "not_found",
|
|
50
|
+
message: `Schedule not found: ${input.id}`,
|
|
51
|
+
next_action_hint:
|
|
52
|
+
"Use schedule_list to see available schedules, or create_schedule to make one.",
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const original = file.content;
|
|
57
|
+
const updated = applyLinePatches(original, input.patches);
|
|
58
|
+
|
|
59
|
+
const parsed = parseScheduleFile(updated, file.mtimeMs);
|
|
60
|
+
if (!parsed.ok) {
|
|
61
|
+
return {
|
|
62
|
+
id: input.id,
|
|
63
|
+
path: filePath,
|
|
64
|
+
applied: 0,
|
|
65
|
+
content: original,
|
|
66
|
+
is_error: true,
|
|
67
|
+
error_type: "invalid_schedule",
|
|
68
|
+
message: `Patched content failed validation: ${parsed.reason}`,
|
|
69
|
+
next_action_hint:
|
|
70
|
+
"Check that frontmatter YAML stays valid and required fields (id, name, frequency, enabled, created_at, updated_at) are preserved.",
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
if (parsed.schedule.id !== input.id) {
|
|
74
|
+
return {
|
|
75
|
+
id: input.id,
|
|
76
|
+
path: filePath,
|
|
77
|
+
applied: 0,
|
|
78
|
+
content: original,
|
|
79
|
+
is_error: true,
|
|
80
|
+
error_type: "id_mismatch",
|
|
81
|
+
message: `frontmatter id '${parsed.schedule.id}' does not match the schedule id '${input.id}'`,
|
|
82
|
+
next_action_hint:
|
|
83
|
+
"Don't change the id frontmatter field; create a new schedule with create_schedule if you need a different id.",
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const fm: ScheduleFrontmatter = {
|
|
88
|
+
id: parsed.schedule.id,
|
|
89
|
+
name: parsed.schedule.name,
|
|
90
|
+
description: parsed.schedule.description,
|
|
91
|
+
frequency: parsed.schedule.frequency,
|
|
92
|
+
enabled: parsed.schedule.enabled,
|
|
93
|
+
last_run_at: parsed.schedule.last_run_at,
|
|
94
|
+
created_at: parsed.schedule.created_at,
|
|
95
|
+
updated_at: new Date().toISOString(),
|
|
96
|
+
};
|
|
97
|
+
const serialized = serializeSchedule(fm, parsed.schedule.body);
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
await atomicWriteIfUnchanged(filePath, serialized, file.mtimeMs);
|
|
101
|
+
} catch (err) {
|
|
102
|
+
if (err instanceof MtimeConflictError) {
|
|
103
|
+
return {
|
|
104
|
+
id: input.id,
|
|
105
|
+
path: filePath,
|
|
106
|
+
applied: 0,
|
|
107
|
+
content: original,
|
|
108
|
+
is_error: true,
|
|
109
|
+
error_type: "mtime_conflict",
|
|
110
|
+
message: `Schedule was modified concurrently: ${err.message}`,
|
|
111
|
+
next_action_hint:
|
|
112
|
+
"Re-read the schedule with schedule_list and recompute your patch line numbers before retrying.",
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
throw err;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
id: input.id,
|
|
120
|
+
path: filePath,
|
|
121
|
+
applied: input.patches.length,
|
|
122
|
+
content: serialized,
|
|
123
|
+
is_error: false,
|
|
124
|
+
};
|
|
125
|
+
},
|
|
126
|
+
} satisfies ToolDefinition<typeof inputSchema, typeof outputSchema>;
|
package/src/tools/skill/edit.ts
CHANGED
|
@@ -1,22 +1,13 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { getSkillsDir } from "../../constants.ts";
|
|
4
|
+
import { applyLinePatches, LinePatchSchema } from "../../fs/patches.ts";
|
|
4
5
|
import { parseSkillFile } from "../../skills/parser.ts";
|
|
5
6
|
import type { ToolDefinition } from "../tool.ts";
|
|
6
7
|
|
|
7
|
-
const PatchSchema = z.object({
|
|
8
|
-
start_line: z.number().describe("1-based inclusive start line"),
|
|
9
|
-
end_line: z
|
|
10
|
-
.number()
|
|
11
|
-
.describe("1-based inclusive end line (0 to insert without replacing)"),
|
|
12
|
-
content: z
|
|
13
|
-
.string()
|
|
14
|
-
.describe("Replacement text (empty string to delete lines)"),
|
|
15
|
-
});
|
|
16
|
-
|
|
17
8
|
const inputSchema = z.object({
|
|
18
9
|
name: z.string().describe("Skill name (case-insensitive)"),
|
|
19
|
-
patches: z.array(
|
|
10
|
+
patches: z.array(LinePatchSchema).describe("Patches to apply"),
|
|
20
11
|
});
|
|
21
12
|
|
|
22
13
|
const outputSchema = z.object({
|
|
@@ -30,27 +21,6 @@ const outputSchema = z.object({
|
|
|
30
21
|
next_action_hint: z.string().optional(),
|
|
31
22
|
});
|
|
32
23
|
|
|
33
|
-
function applyPatches(
|
|
34
|
-
raw: string,
|
|
35
|
-
patches: Array<{ start_line: number; end_line: number; content: string }>,
|
|
36
|
-
): string {
|
|
37
|
-
const lines = raw.split("\n");
|
|
38
|
-
const sorted = [...patches].sort((a, b) => b.start_line - a.start_line);
|
|
39
|
-
|
|
40
|
-
for (const patch of sorted) {
|
|
41
|
-
if (patch.end_line === 0) {
|
|
42
|
-
const insertLines = patch.content === "" ? [] : patch.content.split("\n");
|
|
43
|
-
lines.splice(patch.start_line - 1, 0, ...insertLines);
|
|
44
|
-
} else {
|
|
45
|
-
const deleteCount = patch.end_line - patch.start_line + 1;
|
|
46
|
-
const insertLines = patch.content === "" ? [] : patch.content.split("\n");
|
|
47
|
-
lines.splice(patch.start_line - 1, deleteCount, ...insertLines);
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
return lines.join("\n");
|
|
52
|
-
}
|
|
53
|
-
|
|
54
24
|
export const skillEditTool = {
|
|
55
25
|
name: "skill_edit",
|
|
56
26
|
description:
|
|
@@ -78,7 +48,7 @@ export const skillEditTool = {
|
|
|
78
48
|
}
|
|
79
49
|
|
|
80
50
|
const original = await file.text();
|
|
81
|
-
const updated =
|
|
51
|
+
const updated = applyLinePatches(original, input.patches);
|
|
82
52
|
|
|
83
53
|
try {
|
|
84
54
|
const parsed = parseSkillFile(updated, filePath);
|
package/src/tools/task/create.ts
CHANGED
|
@@ -55,7 +55,9 @@ export const createTaskTool = {
|
|
|
55
55
|
blocked_by: input.blocked_by,
|
|
56
56
|
context_paths: input.context_paths,
|
|
57
57
|
});
|
|
58
|
-
|
|
58
|
+
const msg = `Created subtask: ${newTask.name} (${newTask.id})`;
|
|
59
|
+
if (ctx.notify) ctx.notify(msg);
|
|
60
|
+
else logger.info(msg);
|
|
59
61
|
return {
|
|
60
62
|
id: newTask.id,
|
|
61
63
|
name: newTask.name,
|
package/src/tools/task/delete.ts
CHANGED
|
@@ -44,7 +44,9 @@ export const deleteTaskTool = {
|
|
|
44
44
|
is_error: true,
|
|
45
45
|
};
|
|
46
46
|
}
|
|
47
|
-
|
|
47
|
+
const msg = `Deleted task: ${existing.name} (${existing.id})`;
|
|
48
|
+
if (ctx.notify) ctx.notify(msg);
|
|
49
|
+
else logger.info(msg);
|
|
48
50
|
return {
|
|
49
51
|
deleted_id: existing.id,
|
|
50
52
|
message: `Deleted task "${existing.name}" (${existing.id})`,
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import {
|
|
3
|
+
atomicWriteIfUnchanged,
|
|
4
|
+
MtimeConflictError,
|
|
5
|
+
readWithMtime,
|
|
6
|
+
} from "../../fs/atomic.ts";
|
|
7
|
+
import { applyLinePatches, LinePatchSchema } from "../../fs/patches.ts";
|
|
8
|
+
import type { TaskFrontmatter } from "../../tasks/schema.ts";
|
|
9
|
+
import {
|
|
10
|
+
CircularDependencyError,
|
|
11
|
+
parseTaskFile,
|
|
12
|
+
serializeTask,
|
|
13
|
+
taskFilePath,
|
|
14
|
+
validateBlockedBy,
|
|
15
|
+
} from "../../tasks/store.ts";
|
|
16
|
+
import type { ToolDefinition } from "../tool.ts";
|
|
17
|
+
|
|
18
|
+
const inputSchema = z.object({
|
|
19
|
+
id: z.string().describe("Task id"),
|
|
20
|
+
patches: z.array(LinePatchSchema).describe("Patches to apply"),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const outputSchema = z.object({
|
|
24
|
+
id: z.string(),
|
|
25
|
+
path: z.string().nullable(),
|
|
26
|
+
applied: z.number(),
|
|
27
|
+
content: z.string(),
|
|
28
|
+
is_error: z.boolean(),
|
|
29
|
+
error_type: z.string().optional(),
|
|
30
|
+
message: z.string().optional(),
|
|
31
|
+
next_action_hint: z.string().optional(),
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
export const taskEditTool = {
|
|
35
|
+
name: "task_edit",
|
|
36
|
+
description:
|
|
37
|
+
"[[ bash equivalent command: patch ]] Apply git-style line-range patches to a task file. Operates on the whole file (frontmatter + body). Only pending tasks may be edited. Patches that fail validation or introduce a circular dependency are rejected without writing. Re-serializes to canonicalize YAML and bump updated_at.",
|
|
38
|
+
group: "task",
|
|
39
|
+
inputSchema,
|
|
40
|
+
outputSchema,
|
|
41
|
+
execute: async (input, ctx) => {
|
|
42
|
+
const filePath = taskFilePath(ctx.projectDir, input.id);
|
|
43
|
+
const file = await readWithMtime(filePath);
|
|
44
|
+
if (!file) {
|
|
45
|
+
return {
|
|
46
|
+
id: input.id,
|
|
47
|
+
path: null,
|
|
48
|
+
applied: 0,
|
|
49
|
+
content: "",
|
|
50
|
+
is_error: true,
|
|
51
|
+
error_type: "not_found",
|
|
52
|
+
message: `Task not found: ${input.id}`,
|
|
53
|
+
next_action_hint:
|
|
54
|
+
"Use list_tasks to see available tasks, or create_task to make one.",
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const original = file.content;
|
|
59
|
+
const preParsed = parseTaskFile(original, file.mtimeMs);
|
|
60
|
+
if (!preParsed.ok) {
|
|
61
|
+
return {
|
|
62
|
+
id: input.id,
|
|
63
|
+
path: filePath,
|
|
64
|
+
applied: 0,
|
|
65
|
+
content: original,
|
|
66
|
+
is_error: true,
|
|
67
|
+
error_type: "invalid_task",
|
|
68
|
+
message: `Existing task file is malformed: ${preParsed.reason}`,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
if (preParsed.task.status !== "pending") {
|
|
72
|
+
return {
|
|
73
|
+
id: input.id,
|
|
74
|
+
path: filePath,
|
|
75
|
+
applied: 0,
|
|
76
|
+
content: original,
|
|
77
|
+
is_error: true,
|
|
78
|
+
error_type: "not_pending",
|
|
79
|
+
message: `Cannot edit task ${input.id}: only pending tasks can be edited (current status: ${preParsed.task.status})`,
|
|
80
|
+
next_action_hint:
|
|
81
|
+
"Use complete_task / fail_task / wait_task for terminal status changes.",
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const updated = applyLinePatches(original, input.patches);
|
|
86
|
+
const parsed = parseTaskFile(updated, file.mtimeMs);
|
|
87
|
+
if (!parsed.ok) {
|
|
88
|
+
return {
|
|
89
|
+
id: input.id,
|
|
90
|
+
path: filePath,
|
|
91
|
+
applied: 0,
|
|
92
|
+
content: original,
|
|
93
|
+
is_error: true,
|
|
94
|
+
error_type: "invalid_task",
|
|
95
|
+
message: `Patched content failed validation: ${parsed.reason}`,
|
|
96
|
+
next_action_hint:
|
|
97
|
+
"Check that frontmatter YAML stays valid and required fields (id, name, status, priority, etc.) are preserved.",
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
if (parsed.task.id !== input.id) {
|
|
101
|
+
return {
|
|
102
|
+
id: input.id,
|
|
103
|
+
path: filePath,
|
|
104
|
+
applied: 0,
|
|
105
|
+
content: original,
|
|
106
|
+
is_error: true,
|
|
107
|
+
error_type: "id_mismatch",
|
|
108
|
+
message: `frontmatter id '${parsed.task.id}' does not match the task id '${input.id}'`,
|
|
109
|
+
next_action_hint:
|
|
110
|
+
"Don't change the id frontmatter field; create a new task with create_task if you need a different id.",
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
if (parsed.task.status !== "pending") {
|
|
114
|
+
return {
|
|
115
|
+
id: input.id,
|
|
116
|
+
path: filePath,
|
|
117
|
+
applied: 0,
|
|
118
|
+
content: original,
|
|
119
|
+
is_error: true,
|
|
120
|
+
error_type: "status_change_forbidden",
|
|
121
|
+
message: `Patch would change task status from 'pending' to '${parsed.task.status}'. Status transitions must go through complete_task / fail_task / wait_task so the terminal-tool loop and summary are recorded.`,
|
|
122
|
+
next_action_hint:
|
|
123
|
+
"Don't edit the status frontmatter field; use complete_task, fail_task, or wait_task to transition state.",
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
// Worker-managed fields: claim state is set by claimNextTask /
|
|
127
|
+
// releaseTaskLock, output by complete_task, waiting_reason by wait_task.
|
|
128
|
+
// A pending task should have all four null; refuse any patch that
|
|
129
|
+
// changes them so the agent can't backdoor a claim or fake an output.
|
|
130
|
+
const workerManaged: Array<keyof typeof parsed.task> = [
|
|
131
|
+
"claimed_by",
|
|
132
|
+
"claimed_at",
|
|
133
|
+
"output",
|
|
134
|
+
"waiting_reason",
|
|
135
|
+
];
|
|
136
|
+
for (const field of workerManaged) {
|
|
137
|
+
if (parsed.task[field] !== preParsed.task[field]) {
|
|
138
|
+
return {
|
|
139
|
+
id: input.id,
|
|
140
|
+
path: filePath,
|
|
141
|
+
applied: 0,
|
|
142
|
+
content: original,
|
|
143
|
+
is_error: true,
|
|
144
|
+
error_type: "worker_field_change_forbidden",
|
|
145
|
+
message: `Patch would change worker-managed field '${field}'. Only complete_task / fail_task / wait_task / the claim loop may set claimed_by, claimed_at, output, and waiting_reason.`,
|
|
146
|
+
next_action_hint: `Don't edit the ${field} frontmatter field.`,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
await validateBlockedBy(ctx.projectDir, input.id, parsed.task.blocked_by);
|
|
153
|
+
} catch (err) {
|
|
154
|
+
if (err instanceof CircularDependencyError) {
|
|
155
|
+
return {
|
|
156
|
+
id: input.id,
|
|
157
|
+
path: filePath,
|
|
158
|
+
applied: 0,
|
|
159
|
+
content: original,
|
|
160
|
+
is_error: true,
|
|
161
|
+
error_type: "circular_dependency",
|
|
162
|
+
message: err.message,
|
|
163
|
+
next_action_hint:
|
|
164
|
+
"Pick blockers that don't transitively depend on this task.",
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
throw err;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const fm: TaskFrontmatter = {
|
|
171
|
+
id: parsed.task.id,
|
|
172
|
+
name: parsed.task.name,
|
|
173
|
+
description: parsed.task.description,
|
|
174
|
+
priority: parsed.task.priority,
|
|
175
|
+
status: parsed.task.status,
|
|
176
|
+
blocked_by: parsed.task.blocked_by,
|
|
177
|
+
context_paths: parsed.task.context_paths,
|
|
178
|
+
output: parsed.task.output,
|
|
179
|
+
waiting_reason: parsed.task.waiting_reason,
|
|
180
|
+
claimed_by: parsed.task.claimed_by,
|
|
181
|
+
claimed_at: parsed.task.claimed_at,
|
|
182
|
+
created_at: parsed.task.created_at,
|
|
183
|
+
updated_at: new Date().toISOString(),
|
|
184
|
+
};
|
|
185
|
+
const serialized = serializeTask(fm, parsed.task.body);
|
|
186
|
+
|
|
187
|
+
try {
|
|
188
|
+
await atomicWriteIfUnchanged(filePath, serialized, file.mtimeMs);
|
|
189
|
+
} catch (err) {
|
|
190
|
+
if (err instanceof MtimeConflictError) {
|
|
191
|
+
return {
|
|
192
|
+
id: input.id,
|
|
193
|
+
path: filePath,
|
|
194
|
+
applied: 0,
|
|
195
|
+
content: original,
|
|
196
|
+
is_error: true,
|
|
197
|
+
error_type: "mtime_conflict",
|
|
198
|
+
message: `Task was modified concurrently: ${err.message}`,
|
|
199
|
+
next_action_hint:
|
|
200
|
+
"Re-read the task with view_task and recompute your patch line numbers before retrying.",
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
throw err;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
id: input.id,
|
|
208
|
+
path: filePath,
|
|
209
|
+
applied: input.patches.length,
|
|
210
|
+
content: serialized,
|
|
211
|
+
is_error: false,
|
|
212
|
+
};
|
|
213
|
+
},
|
|
214
|
+
} satisfies ToolDefinition<typeof inputSchema, typeof outputSchema>;
|
package/src/tools/task/update.ts
CHANGED
|
@@ -91,7 +91,9 @@ export const updateTaskTool = {
|
|
|
91
91
|
};
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
-
|
|
94
|
+
const msg = `Updated task: ${updated.name} (${updated.id})`;
|
|
95
|
+
if (ctx.notify) ctx.notify(msg);
|
|
96
|
+
else logger.info(msg);
|
|
95
97
|
return {
|
|
96
98
|
task: {
|
|
97
99
|
id: updated.id,
|
package/src/tools/tool.ts
CHANGED
|
@@ -22,6 +22,13 @@ export interface ToolContext {
|
|
|
22
22
|
* Esc-to-abort by reading `session.aborted`. Workers leave this `undefined`.
|
|
23
23
|
*/
|
|
24
24
|
shouldAbort?: () => boolean;
|
|
25
|
+
/**
|
|
26
|
+
* Chat-mode only. Tools call this to surface a short human-readable
|
|
27
|
+
* side-effect message (e.g. "Created subtask: …") that the TUI renders
|
|
28
|
+
* inside the tool-call card. Workers leave this `undefined`; tools fall
|
|
29
|
+
* back to `logger.info` so worker logs are unchanged.
|
|
30
|
+
*/
|
|
31
|
+
notify?: (message: string) => void;
|
|
25
32
|
}
|
|
26
33
|
|
|
27
34
|
type ToolOutputBase = { is_error: z.ZodBoolean };
|
package/src/tui/App.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Box, Static, Text, useApp, useInput } from "ink";
|
|
1
|
+
import { Box, Static, Text, useApp, useInput, useStdout } from "ink";
|
|
2
2
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
3
3
|
import {
|
|
4
4
|
abortActiveStream,
|
|
@@ -65,7 +65,7 @@ const TAB_BY_CTRL_KEY: Record<string, TabId> = {
|
|
|
65
65
|
o: 2, // t[o]ols
|
|
66
66
|
n: 3, // co[n]text
|
|
67
67
|
t: 4, // [t]asks
|
|
68
|
-
|
|
68
|
+
e: 5, // thr[e]ads
|
|
69
69
|
s: 6, // [s]chedules
|
|
70
70
|
w: 7, // [w]orkers
|
|
71
71
|
g: 8, // help (also catches Ctrl+/ on terminals that map it to BEL)
|
|
@@ -174,9 +174,33 @@ function AppInner({
|
|
|
174
174
|
initialPrompt,
|
|
175
175
|
}: AppInnerProps) {
|
|
176
176
|
const { exit } = useApp();
|
|
177
|
+
const { stdout } = useStdout();
|
|
177
178
|
const { markActivity } = useIdle();
|
|
179
|
+
// Pin the root box to a known viewport height so the rendered frame size
|
|
180
|
+
// never crosses the viewport boundary. Ink 7's renderer wipes scrollback
|
|
181
|
+
// (`shouldClearTerminalForFrame` → `ansiEscapes.clearTerminal`) whenever
|
|
182
|
+
// the dynamic frame transitions in/out of fullscreen, so a fluctuating
|
|
183
|
+
// `outputHeight` (streaming text + tool boxes appearing/disappearing) used
|
|
184
|
+
// to delete the chat history on every turn. Ink doesn't pin a height on
|
|
185
|
+
// its internal root, so a `height="100%"` on our root collapses to `auto`
|
|
186
|
+
// — we have to pass the explicit row count and re-read it on resize.
|
|
187
|
+
const [rows, setRows] = useState(stdout?.rows ?? 24);
|
|
188
|
+
useEffect(() => {
|
|
189
|
+
if (!stdout) return;
|
|
190
|
+
const onResize = () => setRows(stdout.rows ?? 24);
|
|
191
|
+
stdout.on("resize", onResize);
|
|
192
|
+
return () => {
|
|
193
|
+
stdout.off("resize", onResize);
|
|
194
|
+
};
|
|
195
|
+
}, [stdout]);
|
|
178
196
|
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
|
179
197
|
const [messagesEpoch, setMessagesEpoch] = useState(0);
|
|
198
|
+
// `clearing` gates new submissions while /clear's async work is in flight.
|
|
199
|
+
// Without it, a message submitted during the clearChatSession await runs
|
|
200
|
+
// sendMessage against the OLD thread id, then the IIFE's setMessages([sys])
|
|
201
|
+
// overwrites the user bubble it added — the message disappears.
|
|
202
|
+
const [clearing, setClearing] = useState(false);
|
|
203
|
+
const clearingRef = useRef(false);
|
|
180
204
|
const [usage, setUsage] = useState<ContextUsage | null>(null);
|
|
181
205
|
const [inputValue, setInputValue] = useState("");
|
|
182
206
|
const [inputHistory, setInputHistory] = useState<string[]>([]);
|
|
@@ -328,9 +352,18 @@ function AppInner({
|
|
|
328
352
|
slashCommandsRef.current,
|
|
329
353
|
);
|
|
330
354
|
if (popupOpen) return;
|
|
355
|
+
// Ctrl+E edits a queued message when one is selected; only
|
|
356
|
+
// fall through to the Threads tab-jump when the queue is empty.
|
|
357
|
+
if (input === "e" && queuedMessagesRef.current.length > 0) {
|
|
358
|
+
// handled by the queue keybindings block below
|
|
359
|
+
} else {
|
|
360
|
+
setActiveTab(tabForKey);
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
} else {
|
|
364
|
+
setActiveTab(tabForKey);
|
|
365
|
+
return;
|
|
331
366
|
}
|
|
332
|
-
setActiveTab(tabForKey);
|
|
333
|
-
return;
|
|
334
367
|
}
|
|
335
368
|
}
|
|
336
369
|
|
|
@@ -476,6 +509,14 @@ function AppInner({
|
|
|
476
509
|
}
|
|
477
510
|
setActiveToolCalls([...pendingToolCalls]);
|
|
478
511
|
},
|
|
512
|
+
onToolNotify: (id, message) => {
|
|
513
|
+
markActivityRef.current();
|
|
514
|
+
const tc = pendingToolCalls.find((t) => t.id === id);
|
|
515
|
+
if (tc) {
|
|
516
|
+
tc.notes = [...(tc.notes ?? []), message];
|
|
517
|
+
setActiveToolCalls([...pendingToolCalls]);
|
|
518
|
+
}
|
|
519
|
+
},
|
|
479
520
|
onUsage: (info) => {
|
|
480
521
|
setUsage(info);
|
|
481
522
|
},
|
|
@@ -569,6 +610,8 @@ function AppInner({
|
|
|
569
610
|
async (text: string) => {
|
|
570
611
|
const trimmed = text.trim();
|
|
571
612
|
if (!trimmed || !sessionRef.current) return;
|
|
613
|
+
// /clear is mid-flight: don't queue against the old thread id.
|
|
614
|
+
if (clearingRef.current) return;
|
|
572
615
|
|
|
573
616
|
setInputValue("");
|
|
574
617
|
|
|
@@ -639,6 +682,12 @@ function AppInner({
|
|
|
639
682
|
// poll below immediately rather than waiting on the
|
|
640
683
|
// createThread/endThread round trip first.
|
|
641
684
|
abortActiveStream(session);
|
|
685
|
+
// Block new submissions until the new thread id is in place —
|
|
686
|
+
// otherwise the user's first post-/clear message races the
|
|
687
|
+
// async createThread, runs against the old thread id, and is
|
|
688
|
+
// then wiped by setMessages([sys]) below.
|
|
689
|
+
clearingRef.current = true;
|
|
690
|
+
setClearing(true);
|
|
642
691
|
void (async () => {
|
|
643
692
|
// Wait for any in-flight processQueue iteration to finish so
|
|
644
693
|
// its trailing `finalizeSegment` can't race our state reset
|
|
@@ -679,6 +728,9 @@ function AppInner({
|
|
|
679
728
|
timestamp: new Date(),
|
|
680
729
|
},
|
|
681
730
|
]);
|
|
731
|
+
} finally {
|
|
732
|
+
clearingRef.current = false;
|
|
733
|
+
setClearing(false);
|
|
682
734
|
}
|
|
683
735
|
})();
|
|
684
736
|
},
|
|
@@ -757,7 +809,7 @@ function AppInner({
|
|
|
757
809
|
const threadId = sessionRef.current.threadId;
|
|
758
810
|
|
|
759
811
|
return (
|
|
760
|
-
<Box flexDirection="column" height="
|
|
812
|
+
<Box flexDirection="column" height={rows} overflow="hidden">
|
|
761
813
|
{/* Completed messages — rendered once to terminal scrollback.
|
|
762
814
|
Must live outside the display="none" tab wrappers so the <Static>
|
|
763
815
|
node always has proper terminal width in its Yoga layout.
|
|
@@ -769,11 +821,19 @@ function AppInner({
|
|
|
769
821
|
|
|
770
822
|
{/* Tab content area — all panels stay mounted to avoid expensive
|
|
771
823
|
remount cycles. display="none" hides inactive panels from
|
|
772
|
-
layout without destroying them.
|
|
824
|
+
layout without destroying them.
|
|
825
|
+
The chat tab's flexGrow box is overflow-clipped so streaming
|
|
826
|
+
content can't push the rendered frame past the viewport — see
|
|
827
|
+
the comment on the `rows` state for why that matters.
|
|
828
|
+
`justifyContent="flex-end"` keeps active streaming content + the
|
|
829
|
+
tool-call card pinned to the bottom of the chat area (just
|
|
830
|
+
above the input bar) instead of leaving a tall gap below them. */}
|
|
773
831
|
<Box
|
|
774
832
|
display={activeTab === 1 ? "flex" : "none"}
|
|
775
833
|
flexDirection="column"
|
|
776
834
|
flexGrow={1}
|
|
835
|
+
overflow="hidden"
|
|
836
|
+
justifyContent="flex-end"
|
|
777
837
|
>
|
|
778
838
|
<MessageList
|
|
779
839
|
streamingText={streamingText}
|
|
@@ -854,7 +914,7 @@ function AppInner({
|
|
|
854
914
|
value={inputValue}
|
|
855
915
|
onChange={setInputValue}
|
|
856
916
|
onSubmit={handleSubmit}
|
|
857
|
-
disabled={activeTab !== 1}
|
|
917
|
+
disabled={activeTab !== 1 || clearing}
|
|
858
918
|
history={inputHistory}
|
|
859
919
|
header={inputBarHeader}
|
|
860
920
|
slashCommands={slashCommands}
|
|
@@ -236,8 +236,9 @@ export const ContextPanel = memo(function ContextPanel({
|
|
|
236
236
|
deleteConfirm.pressDelete(entry.path);
|
|
237
237
|
return;
|
|
238
238
|
}
|
|
239
|
-
if (input === "r") {
|
|
239
|
+
if (key.ctrl && (input === "r" || input === "R")) {
|
|
240
240
|
refresh(currentPathRef.current);
|
|
241
|
+
return;
|
|
241
242
|
}
|
|
242
243
|
},
|
|
243
244
|
{ isActive },
|
|
@@ -367,7 +368,7 @@ export const ContextPanel = memo(function ContextPanel({
|
|
|
367
368
|
<Text dimColor>
|
|
368
369
|
{focus === "detail"
|
|
369
370
|
? "↑↓ scroll · ⇧↑↓ page · g/G top/bot · ← back to list"
|
|
370
|
-
: "↑↓ select · → drill in/enter detail · ← up · d delete (×2) ·
|
|
371
|
+
: "↑↓ select · → drill in/enter detail · ← up · d delete (×2) · ^R refresh"}
|
|
371
372
|
</Text>
|
|
372
373
|
</Box>
|
|
373
374
|
</Box>
|
|
@@ -56,7 +56,7 @@ export const HelpPanel = memo(function HelpPanel({
|
|
|
56
56
|
{" "}Ctrl+t{" "}Tasks
|
|
57
57
|
</Text>
|
|
58
58
|
<Text>
|
|
59
|
-
{" "}Ctrl+
|
|
59
|
+
{" "}Ctrl+e{" "}Threads
|
|
60
60
|
</Text>
|
|
61
61
|
<Text>
|
|
62
62
|
{" "}Ctrl+s{" "}Schedules
|
|
@@ -142,19 +142,21 @@ export const HelpPanel = memo(function HelpPanel({
|
|
|
142
142
|
(cancels on any other key or after 3s)
|
|
143
143
|
</Text>
|
|
144
144
|
<Text>
|
|
145
|
-
{" "}
|
|
146
|
-
|
|
145
|
+
{" "}Ctrl+R{" "}Refresh (Context · Tasks · Threads ·
|
|
146
|
+
Schedules · Workers)
|
|
147
|
+
</Text>
|
|
148
|
+
<Text>
|
|
149
|
+
{" "}Tasks{" "}f filter · p priority · d delete (×2)
|
|
147
150
|
</Text>
|
|
148
151
|
<Text>
|
|
149
152
|
{" "}Threads{" "}f filter · s/ search · w follow · d delete
|
|
150
|
-
(×2)
|
|
153
|
+
(×2)
|
|
151
154
|
</Text>
|
|
152
155
|
<Text>
|
|
153
|
-
{" "}Schedules{" "}f filter · e toggle · d delete (×2)
|
|
154
|
-
refresh
|
|
156
|
+
{" "}Schedules{" "}f filter · e toggle · d delete (×2)
|
|
155
157
|
</Text>
|
|
156
158
|
<Text>
|
|
157
|
-
{" "}Context{" "}d delete (×2)
|
|
159
|
+
{" "}Context{" "}d delete (×2)
|
|
158
160
|
</Text>
|
|
159
161
|
<Text>
|
|
160
162
|
{" "}Workers{" "}f filter · l toggle log/detail · d delete log
|
|
@@ -207,7 +207,7 @@ export const SchedulePanel = memo(function SchedulePanel({
|
|
|
207
207
|
deleteConfirm.pressDelete(s.name);
|
|
208
208
|
return;
|
|
209
209
|
}
|
|
210
|
-
if (input === "r") {
|
|
210
|
+
if (key.ctrl && (input === "r" || input === "R")) {
|
|
211
211
|
forceRefresh();
|
|
212
212
|
return;
|
|
213
213
|
}
|
|
@@ -338,7 +338,7 @@ export const SchedulePanel = memo(function SchedulePanel({
|
|
|
338
338
|
<Text dimColor>
|
|
339
339
|
{focus === "detail"
|
|
340
340
|
? "↑↓ scroll · ⇧↑↓ page · g/G top/bot · ← back to list"
|
|
341
|
-
: "↑↓ select · → enter detail · f filter · e toggle · d delete (×2) ·
|
|
341
|
+
: "↑↓ select · → enter detail · f filter · e toggle · d delete (×2) · ^R refresh"}
|
|
342
342
|
</Text>
|
|
343
343
|
</Box>
|
|
344
344
|
</Box>
|