Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/agent-core/src/tools/builtin/state/todo-list.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,10 @@ Use this tool to maintain a structured TODO list as you work through a multi-ste
- If no available tool can move any task forward, tell the user where you are stuck instead of repeatedly re-ordering the same todos.

**How to use:**
- Call with `todos: [...]` to replace the full list. Statuses: pending / in_progress / done.
- Call with `todos: [...]` to replace the full list. Statuses: `pending` / `in_progress` / `done`.
- Call with no `todos` argument to retrieve the current list without changing it.
- Call with `todos: []` to clear the list.
- **Important:** the status must be exactly `done`, not `completed` or `finished`.
- Keep titles short and actionable (e.g. "Read session-control.ts", "Add planMode flag to TurnManager").
- Update statuses as you make progress.
- When work is underway, keep exactly one task `in_progress`.
Expand Down
12 changes: 9 additions & 3 deletions packages/agent-core/src/tools/builtin/state/todo-list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export const TODO_STORE_KEY = 'todo';
const TODO_LIST_WRITE_REMINDER =
'Ensure that you continue to use the todo list to track progress. Mark tasks done immediately after finishing them, and keep exactly one task in_progress when work is underway.';

export type TodoStatus = 'pending' | 'in_progress' | 'done';
export type TodoStatus = 'pending' | 'in_progress' | 'done' | 'completed';

export interface TodoItem {
readonly title: string;
Expand All @@ -45,7 +45,9 @@ declare module '../../store' {

const TodoItemSchema = z.object({
title: z.string().min(1).describe('Short, actionable title for the todo.'),
status: z.enum(['pending', 'in_progress', 'done']).describe('Current status of the todo.'),
status: z
.preprocess((val) => (val === 'completed' ? 'done' : val), z.enum(['pending', 'in_progress', 'done']))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Allow completed through tool-argument validation

In real tool calls, preflightToolCall validates the provider arguments with AJV against tool.parameters and never runs TodoListInputSchema.safeParse, so this preprocess does not normalize the raw "completed" value before validation. The JSON schema advertised from this schema still describes the inner enum (pending/in_progress/done), so a model call using the compatibility spelling is rejected before setTodos can map it to done; the new test bypasses that path by invoking resolveExecution directly. Please make the JSON schema accept completed as well, or run the Zod parse/normalization before AJV rejects the call.

Useful? React with 👍 / 👎.

.describe('Current status of the todo. Must be exactly one of: pending, in_progress, done. Do NOT use completed or finished.'),
});

export interface TodoListInput {
Expand Down Expand Up @@ -81,6 +83,7 @@ function statusMarker(status: TodoStatus): string {
case 'in_progress':
return '[in_progress]';
case 'done':
case 'completed':
return '[done]';
default: {
const _exhaustive: never = status;
Expand Down Expand Up @@ -133,7 +136,10 @@ export class TodoListTool implements BuiltinTool<TodoListInput> {
private setTodos(todos: readonly TodoItem[]): void {
this.store.set(
TODO_STORE_KEY,
todos.map((todo) => ({ title: todo.title, status: todo.status })),
todos.map((todo) => ({
title: todo.title,
status: todo.status === 'completed' ? 'done' : todo.status,
})),
);
}
}
17 changes: 17 additions & 0 deletions packages/agent-core/test/tools/todo-list.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,23 @@ describe('TodoListTool', () => {
]);
});

it('accepts "completed" as a status and maps it to "done"', async () => {
const { tool, getTodos } = makeTool();

const result = await executeTool(tool, {
turnId: 't1',
toolCallId: 'call_1',
args: {
todos: [{ title: 'done task', status: 'completed' }],
},
signal,
});

expect(result).toMatchObject({ isError: false });
expect(result.output).toContain('[done] done task');
expect(getTodos()).toEqual([{ title: 'done task', status: 'done' }]);
});

it('renders a done todo with a marker matching the status enum value', async () => {
const { tool } = makeTool([{ title: 'shipped', status: 'done' }]);

Expand Down
10 changes: 10 additions & 0 deletions packages/pi-tui/src/tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,7 @@ export class TUI extends Container {
private static readonly MIN_RENDER_INTERVAL_MS = 16;
private cursorRow = 0; // Logical cursor row (end of rendered content)
private hardwareCursorRow = 0; // Actual terminal cursor row (may differ due to IME positioning)
private hardwareCursorCol = 0; // Actual terminal cursor column (tracked to avoid spurious ANSI writes)
private showHardwareCursor = process.env['PI_HARDWARE_CURSOR'] === "1";
private clearOnShrink = process.env['PI_CLEAR_ON_SHRINK'] === "1"; // Clear empty rows when content shrinks (default: off)
private maxLinesRendered = 0; // Track terminal's working area (max lines ever rendered)
Expand Down Expand Up @@ -726,6 +727,7 @@ export class TUI extends Container {
this.previousHeight = -1; // -1 triggers heightChanged, forcing a full clear
this.cursorRow = 0;
this.hardwareCursorRow = 0;
this.hardwareCursorCol = 0;
this.maxLinesRendered = 0;
this.previousViewportTop = 0;
if (this.renderTimer) {
Expand Down Expand Up @@ -1653,6 +1655,13 @@ export class TUI extends Container {
const targetRow = Math.max(0, Math.min(cursorPos.row, totalLines - 1));
const targetCol = Math.max(0, cursorPos.col);

// Skip repositioning when cursor hasn't moved and hardware cursor is hidden.
// This avoids spurious ANSI sequences that can cause viewport scroll jumps
// on terminals with Kitty keyboard protocol support.
if (!this.showHardwareCursor && targetRow === this.hardwareCursorRow && targetCol === this.hardwareCursorCol) {
return;
Comment on lines +1661 to +1662

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Skip cursor writes only after true no-op renders

After a frame rewrites terminal lines, doRender updates hardwareCursorRow to the final rendered row before calling this method, but hardwareCursorCol still contains the previous requested cursor column rather than the column where the write actually left the terminal. When the logical cursor is on that final row and its column is unchanged, this guard returns without emitting the absolute-column move, leaving the physical cursor at the end of the rewritten line instead of at the cursor marker; that breaks IME candidate placement and leaves subsequent cursor state out of sync. Please only suppress the ANSI write for frames that did not already move the terminal cursor, or refresh the tracked column before comparing.

Useful? React with 👍 / 👎.

}

// Move cursor from current position to target
const rowDelta = targetRow - this.hardwareCursorRow;
let buffer = "";
Expand All @@ -1669,6 +1678,7 @@ export class TUI extends Container {
}

this.hardwareCursorRow = targetRow;
this.hardwareCursorCol = targetCol;
if (this.showHardwareCursor) {
this.terminal.showCursor();
} else {
Expand Down
Loading