@thegitai/cli 1.0.0-beta.19 → 1.0.0-beta.20

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/README.md CHANGED
@@ -26,6 +26,23 @@ ai --version print the version and exit
26
26
 
27
27
  Run `ai --help` for sessions, modes, keys, and chat commands.
28
28
 
29
+ ## Visible to-do list
30
+
31
+ For larger multi-step tasks, the agent keeps a compact to-do list on screen so
32
+ you can see what it plans to do, what it is working on right now, and what is
33
+ already done.
34
+
35
+ - While the agent works, the list sits at the bottom of the live **Working**
36
+ area, right above where you type, and updates as steps start and finish —
37
+ one step in progress at a time — so it stays visible even when tool output
38
+ above it runs long.
39
+ - When the turn ends, a final snapshot of the list stays readable in the
40
+ transcript, and the footer shows a small progress chip (e.g. `◐ 4/6 to-dos`)
41
+ while steps remain open.
42
+ - The agent's current reasoning stays visible too, right below the list, in a
43
+ compact one-line form so it doesn't compete with the list for space.
44
+ - The list is managed entirely by the agent; simple one-step requests skip it.
45
+
29
46
  ## Background jobs
30
47
 
31
48
  Some commands are meant to keep running — a dev server, a file watcher, a local
@@ -13,6 +13,7 @@ const PLAN_MODE_TOOL_NAMES = new Set([
13
13
  'analyze_image',
14
14
  'run_command',
15
15
  'shell_job_output',
16
+ 'update_todos',
16
17
  ]);
17
18
  const PLAN_MODE_RUN_COMMAND_NAMES = new Set([
18
19
  'pwd',
@@ -0,0 +1,106 @@
1
+ export const MAX_TODO_ITEMS = 20;
2
+ export const MAX_TODO_TEXT_CHARS = 160;
3
+ let items = [];
4
+ let activeSessionId = null;
5
+ const STATUS_ALIASES = {
6
+ pending: 'pending',
7
+ todo: 'pending',
8
+ not_started: 'pending',
9
+ in_progress: 'in_progress',
10
+ active: 'in_progress',
11
+ doing: 'in_progress',
12
+ completed: 'completed',
13
+ complete: 'completed',
14
+ done: 'completed',
15
+ };
16
+ function normalizeStatus(raw) {
17
+ const key = String(raw ?? '')
18
+ .trim()
19
+ .toLowerCase()
20
+ .replace(/[-\s]+/g, '_');
21
+ return STATUS_ALIASES[key] ?? null;
22
+ }
23
+ export function isCompletedStatus(raw) {
24
+ return normalizeStatus(raw) === 'completed';
25
+ }
26
+ const TODOS_ARG_ALIASES = ['items', 'todo_list', 'todoList', 'list', 'tasks'];
27
+ export function extractTodosArg(args) {
28
+ if (!args || typeof args !== 'object')
29
+ return undefined;
30
+ const record = args;
31
+ if (record.todos !== undefined)
32
+ return record.todos;
33
+ for (const key of TODOS_ARG_ALIASES) {
34
+ if (record[key] !== undefined)
35
+ return record[key];
36
+ }
37
+ return undefined;
38
+ }
39
+ export function setTodoSession(sessionId) {
40
+ const next = String(sessionId ?? '').trim() || null;
41
+ if (activeSessionId !== next) {
42
+ items = [];
43
+ }
44
+ activeSessionId = next;
45
+ }
46
+ export function listTodos() {
47
+ return items.map((item) => ({ ...item }));
48
+ }
49
+ export function getTodoSnapshot() {
50
+ return {
51
+ items: listTodos(),
52
+ completedCount: items.filter((item) => item.status === 'completed').length,
53
+ totalCount: items.length,
54
+ };
55
+ }
56
+ export function clearTodos() {
57
+ items = [];
58
+ }
59
+ export function replaceTodos(raw) {
60
+ if (!Array.isArray(raw)) {
61
+ return { ok: false, error: 'todos must be an array of { text, status } items.' };
62
+ }
63
+ const normalizations = [];
64
+ if (raw.length > MAX_TODO_ITEMS) {
65
+ return {
66
+ ok: false,
67
+ error: `todos supports at most ${MAX_TODO_ITEMS} items; got ${raw.length}. Use fewer, broader steps.`,
68
+ };
69
+ }
70
+ const next = [];
71
+ let sawInProgress = false;
72
+ for (const [index, entry] of raw.entries()) {
73
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
74
+ return { ok: false, error: `todos[${index}] must be an object with text and status.` };
75
+ }
76
+ const text = String(entry.text ?? '')
77
+ .replace(/\s+/g, ' ')
78
+ .trim();
79
+ if (!text) {
80
+ return { ok: false, error: `todos[${index}].text must be a non-empty string.` };
81
+ }
82
+ const status = normalizeStatus(entry.status);
83
+ if (!status) {
84
+ return {
85
+ ok: false,
86
+ error: `todos[${index}].status must be one of: pending, in_progress, completed.`,
87
+ };
88
+ }
89
+ let boundedText = text;
90
+ if (boundedText.length > MAX_TODO_TEXT_CHARS) {
91
+ boundedText = `${boundedText.slice(0, MAX_TODO_TEXT_CHARS - 1)}…`;
92
+ normalizations.push(`todos[${index}].text truncated to ${MAX_TODO_TEXT_CHARS} chars`);
93
+ }
94
+ let finalStatus = status;
95
+ if (status === 'in_progress') {
96
+ if (sawInProgress) {
97
+ finalStatus = 'pending';
98
+ normalizations.push(`todos[${index}] demoted to pending: only one item can be in_progress`);
99
+ }
100
+ sawInProgress = true;
101
+ }
102
+ next.push({ text: boundedText, status: finalStatus });
103
+ }
104
+ items = next;
105
+ return { ok: true, snapshot: getTodoSnapshot(), normalizations };
106
+ }
@@ -6,6 +6,7 @@ import { dispatchTool } from './tools/index.js';
6
6
  import { syncIndexFromDisk } from './project-index.js';
7
7
  import { PATH_REPAIRING_EDIT_TOOLS, repairFilePath } from './tools/path-suggest.js';
8
8
  import { invalidateShellDiagnosticsCache, runShellDiagnostics, } from './tools/shell-diagnostics.js';
9
+ import { extractTodosArg } from './todo-list.js';
9
10
  const EDIT_FILE_PATH_ARG_ALIASES = [
10
11
  'filePath',
11
12
  'file_path',
@@ -27,6 +28,13 @@ function toolCallSummary(call) {
27
28
  call.name === 'shell_job_kill') {
28
29
  return String(args.job_id ?? '').trim();
29
30
  }
31
+ if (call.name === 'update_todos') {
32
+ const raw = extractTodosArg(args);
33
+ const todos = Array.isArray(raw) ? raw : [];
34
+ if (todos.length === 0)
35
+ return '';
36
+ return `${todos.length} item${todos.length === 1 ? '' : 's'}`;
37
+ }
30
38
  const filePath = getEditToolFilePath(call);
31
39
  if (filePath)
32
40
  return filePath;
@@ -21,6 +21,7 @@ import { getSignatureHelp } from './signature-help.js';
21
21
  import { shellJobKill } from './shell-job-kill.js';
22
22
  import { strReplace } from './str-replace.js';
23
23
  import { undoEdit } from './undo-edit.js';
24
+ import { updateTodos } from './update-todos.js';
24
25
  import { writeFile } from './write-file.js';
25
26
  export const TOOL_MAP = {
26
27
  search_code: (context, args) => searchCode(context.projectIndex, args),
@@ -48,6 +49,7 @@ export const TOOL_MAP = {
48
49
  run_node_script: runNodeScript,
49
50
  shell_job_output: shellJobOutput,
50
51
  shell_job_kill: shellJobKill,
52
+ update_todos: updateTodos,
51
53
  };
52
54
  function invalidToolCall(error) {
53
55
  return {
@@ -0,0 +1,27 @@
1
+ import { extractTodosArg, replaceTodos } from '../todo-list.js';
2
+ export function updateTodos(_context, args) {
3
+ const rawTodos = extractTodosArg(args);
4
+ if (rawTodos === undefined) {
5
+ return {
6
+ ok: false,
7
+ error: 'todos is required (pass [] to clear the list).',
8
+ failureCategory: 'missing_required_argument',
9
+ };
10
+ }
11
+ const result = replaceTodos(rawTodos);
12
+ if (!result.ok) {
13
+ return {
14
+ ok: false,
15
+ error: result.error,
16
+ failureCategory: 'invalid_argument',
17
+ };
18
+ }
19
+ const { snapshot, normalizations } = result;
20
+ return {
21
+ ok: true,
22
+ todos: snapshot.items,
23
+ completedCount: snapshot.completedCount,
24
+ totalCount: snapshot.totalCount,
25
+ ...(normalizations.length ? { normalizations } : {}),
26
+ };
27
+ }
@@ -1,10 +1,11 @@
1
1
  import { createRatatuiBridge } from './tui/bridge.js';
2
- import { buildTuiFrame, formatJobElapsed, renderTranscriptEntryLines, } from './tui/build-frame.js';
2
+ import { buildTuiFrame, formatJobElapsed, formatTodoProgress, renderTranscriptEntryLines, } from './tui/build-frame.js';
3
3
  export { getSlashCommandSuggestions } from './tui/build-frame.js';
4
4
  import { agentModeLabel, nextAgentMode, } from '../agent-mode.js';
5
5
  import { chat, models } from '../api/index.js';
6
6
  import { isTurnCancelledError } from '../api/chat.js';
7
7
  import { getJobBufferedOutput, getJobOutputPreview, hasRunningBackgroundJobs, killAllBackgroundJobs, killBackgroundJob, listBackgroundJobs, setBackgroundJobSession, setBackgroundJobUpdateHook, } from '../background-jobs.js';
8
+ import { clearTodos, listTodos, setTodoSession } from '../todo-list.js';
8
9
  import { cancelActiveCommand } from '../executor.js';
9
10
  import { setCommandOutputHook, withTuiMode } from '../runtime-mode.js';
10
11
  import { collectBackgroundJobUiKillMutations, collectBackgroundJobUiOutputMutations, } from '../tool-executor.js';
@@ -867,11 +868,16 @@ function createShellStore(initialState) {
867
868
  },
868
869
  appendWorkingTool: (entry) => {
869
870
  const lastEntry = state.workingTools.at(-1);
870
- if (sameTranscriptDraft(lastEntry, entry))
871
+ const isDuplicate = sameTranscriptDraft(lastEntry, entry);
872
+ if (isDuplicate && state.commandLog.length === 0) {
871
873
  return;
874
+ }
872
875
  state = {
873
876
  ...state,
874
- workingTools: [...state.workingTools, entry].slice(-WORKING_TOOL_PREVIEW_ITEMS),
877
+ commandLog: [],
878
+ workingTools: isDuplicate
879
+ ? state.workingTools
880
+ : [...state.workingTools, entry].slice(-WORKING_TOOL_PREVIEW_ITEMS),
875
881
  };
876
882
  notify();
877
883
  },
@@ -956,6 +962,7 @@ function createInitialShellState(session, serverModels, debugUi) {
956
962
  exitConfirmUntil: null,
957
963
  thinkingTitle: '',
958
964
  thinkingNotes: [],
965
+ todos: listTodos(),
959
966
  tokenUsage: formatClientTokenUsage(null),
960
967
  transcript: [],
961
968
  turnCounter: Math.max(0, session.history.filter((entry) => entry.role === 'user').length),
@@ -1008,6 +1015,14 @@ function thinkingNoteFromStatus(status) {
1008
1015
  const note = text.slice('Thinking:'.length).trim();
1009
1016
  return note ? note : null;
1010
1017
  }
1018
+ function stripMarkdownEmphasis(text) {
1019
+ return text
1020
+ .replace(/\*\*\*(.+?)\*\*\*/g, '$1')
1021
+ .replace(/___(.+?)___/g, '$1')
1022
+ .replace(/\*\*(.+?)\*\*/g, '$1')
1023
+ .replace(/__(.+?)__/g, '$1')
1024
+ .replace(/(?<!\*)\*(?!\*)([^*\n]+?)\*(?!\*)/g, '$1');
1025
+ }
1011
1026
  function splitThinkingLines(text) {
1012
1027
  return text
1013
1028
  .split('\n')
@@ -1016,13 +1031,13 @@ function splitThinkingLines(text) {
1016
1031
  .flatMap((line) => line
1017
1032
  .split(/(?<=[.!?])\s+(?=[A-Z0-9"'`])/)
1018
1033
  .map((part) => part.trim())
1019
- .filter(Boolean))
1020
- .map((line) => truncate(line, 120));
1034
+ .filter(Boolean));
1021
1035
  }
1022
1036
  function thinkingPanelFromStatus(status) {
1023
- const text = thinkingNoteFromStatus(status);
1024
- if (!text)
1037
+ const rawText = thinkingNoteFromStatus(status);
1038
+ if (!rawText)
1025
1039
  return null;
1040
+ const text = stripMarkdownEmphasis(rawText);
1026
1041
  const rawLines = text
1027
1042
  .split('\n')
1028
1043
  .map((line) => line.trim())
@@ -1031,12 +1046,12 @@ function thinkingPanelFromStatus(status) {
1031
1046
  return null;
1032
1047
  if (rawLines.length === 1 && rawLines[0].length <= 72) {
1033
1048
  return {
1034
- title: truncate(rawLines[0], 72),
1049
+ title: rawLines[0],
1035
1050
  notes: [],
1036
1051
  };
1037
1052
  }
1038
1053
  const title = rawLines.length > 1 && rawLines[0].length <= 72
1039
- ? truncate(rawLines[0], 72)
1054
+ ? rawLines[0]
1040
1055
  : 'Thinking';
1041
1056
  const bodyLines = rawLines.length > 1 && rawLines[0].length <= 72 ? rawLines.slice(1) : rawLines;
1042
1057
  return {
@@ -1241,6 +1256,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1241
1256
  }
1242
1257
  await withTuiMode(async () => {
1243
1258
  setBackgroundJobSession(session.sessionId);
1259
+ setTodoSession(session.sessionId);
1244
1260
  const store = createShellStore(createInitialShellState(session, serverModels, debugUi));
1245
1261
  store.replaceTranscript(createSessionTranscript(session));
1246
1262
  let currentServerModels = serverModels;
@@ -1263,6 +1279,10 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1263
1279
  let latestUsageSummary = null;
1264
1280
  let pendingTurnEntries = [];
1265
1281
  let activeTurnAbort = null;
1282
+ let todosTouchedThisTurn = false;
1283
+ const syncTodosState = () => {
1284
+ store.update((current) => ({ ...current, todos: listTodos() }));
1285
+ };
1266
1286
  let activeTurnGeneration = 0;
1267
1287
  let exitCtrlCArmed = false;
1268
1288
  let exitCtrlCTimer = null;
@@ -1904,6 +1924,8 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1904
1924
  applySessionSnapshot(session, snapshot);
1905
1925
  setBackgroundJobSession(session.sessionId);
1906
1926
  syncBackgroundJobsState();
1927
+ setTodoSession(session.sessionId);
1928
+ syncTodosState();
1907
1929
  await saveActiveSession();
1908
1930
  syncShellStateFromSession();
1909
1931
  store.update((next) => ({
@@ -2140,6 +2162,8 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2140
2162
  }
2141
2163
  if (input === '/clear') {
2142
2164
  clearConversation(session);
2165
+ clearTodos();
2166
+ syncTodosState();
2143
2167
  latestUsageSummary = null;
2144
2168
  await saveActiveSession();
2145
2169
  store.replaceTranscript([
@@ -2161,6 +2185,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2161
2185
  const turnAbort = new AbortController();
2162
2186
  activeTurnAbort = turnAbort;
2163
2187
  lastTurnStartedAt = turnStartedAt;
2188
+ todosTouchedThisTurn = false;
2164
2189
  const userEntry = {
2165
2190
  body: input,
2166
2191
  kind: 'user',
@@ -2215,6 +2240,22 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2215
2240
  await appendSettledTurnEntries(turnEntries, result.text ?? '', () => turnGeneration === activeTurnGeneration && !turnAbort.signal.aborted);
2216
2241
  if (turnGeneration !== activeTurnGeneration)
2217
2242
  return;
2243
+ if (todosTouchedThisTurn) {
2244
+ todosTouchedThisTurn = false;
2245
+ const todoItems = listTodos();
2246
+ if (todoItems.length > 0) {
2247
+ appendStaticEntry({
2248
+ body: '',
2249
+ kind: 'system',
2250
+ title: `To-dos · ${formatTodoProgress(todoItems)}`,
2251
+ todoList: todoItems,
2252
+ });
2253
+ if (todoItems.every((item) => item.status === 'completed')) {
2254
+ clearTodos();
2255
+ syncTodosState();
2256
+ }
2257
+ }
2258
+ }
2218
2259
  store.update((current) => ({
2219
2260
  ...current,
2220
2261
  busy: false,
@@ -2295,6 +2336,13 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2295
2336
  };
2296
2337
  session.onToolEvent = (event) => {
2297
2338
  syncShellStateFromSession();
2339
+ if (event.call.name === 'update_todos') {
2340
+ syncTodosState();
2341
+ if (event.result?.ok === true) {
2342
+ todosTouchedThisTurn = true;
2343
+ return;
2344
+ }
2345
+ }
2298
2346
  if (isFileChangeTool(event.call.name)) {
2299
2347
  const entry = buildFileChangeEntry(event);
2300
2348
  store.appendWorkingTool(entry);
@@ -2472,6 +2520,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2472
2520
  clearLiveFrameRemountTimer();
2473
2521
  killAllBackgroundJobs();
2474
2522
  setBackgroundJobSession(null);
2523
+ setTodoSession(null);
2475
2524
  setBackgroundJobUpdateHook(null);
2476
2525
  await bridge.close();
2477
2526
  setCommandOutputHook(null);
@@ -5,6 +5,8 @@ import { renderFormattedBodyLines, renderPreformattedBodyLines, } from './markdo
5
5
  import { line, plainLine, span, wrapText } from './text.js';
6
6
  const WORKING_CLOCK_ICON = '◷';
7
7
  const BRAILLE_SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴'];
8
+ const TODO_PANEL_MAX_ROWS = 12;
9
+ const TODO_IN_PROGRESS_COLOR = 'ansi256(214)';
8
10
  const COMMAND_PREVIEW_LINES = 10;
9
11
  const WORKING_TOOL_PREVIEW_ROWS = 3;
10
12
  const TRANSCRIPT_DIFF_PREVIEW_LINES = 24;
@@ -131,7 +133,7 @@ function diffLinePrefix(kind) {
131
133
  return ' ';
132
134
  }
133
135
  }
134
- function fitDiffLine(content, maxWidth) {
136
+ function fitLine(content, maxWidth) {
135
137
  if (maxWidth <= 0)
136
138
  return '';
137
139
  if (content.length <= maxWidth)
@@ -230,6 +232,55 @@ function formatRelativeTime(isoDate) {
230
232
  return `${hours}h ago`;
231
233
  return `${Math.floor(hours / 24)}d ago`;
232
234
  }
235
+ function todoItemLine(item, width) {
236
+ const text = fitLine(item.text, Math.max(8, width - 5));
237
+ if (item.status === 'completed') {
238
+ return line(span(' ✔ ', { color: 'green' }), span(text, { color: 'gray', dim: true }));
239
+ }
240
+ if (item.status === 'in_progress') {
241
+ return line(span(' ◐ ', { color: TODO_IN_PROGRESS_COLOR }), span(text, { color: TODO_IN_PROGRESS_COLOR, bold: true }));
242
+ }
243
+ return line(span(' ○ ', { color: 'gray' }), span(text));
244
+ }
245
+ export function formatTodoProgress(items) {
246
+ const done = items.filter((item) => item.status === 'completed').length;
247
+ return `${done}/${items.length} done`;
248
+ }
249
+ export function renderTodoListLines(items, width, { header = false } = {}) {
250
+ if (items.length === 0)
251
+ return [];
252
+ const lines = [];
253
+ if (header) {
254
+ lines.push(line(span('To-dos', { color: 'cyan', bold: true }), span(` · ${formatTodoProgress(items)}`, { color: 'gray' })));
255
+ }
256
+ const itemRowBudget = TODO_PANEL_MAX_ROWS - (header ? 1 : 0);
257
+ let collapsedDone = 0;
258
+ if (items.length > itemRowBudget) {
259
+ const over = items.length - (itemRowBudget - 1);
260
+ let leadingDone = 0;
261
+ while (leadingDone < items.length &&
262
+ items[leadingDone].status === 'completed') {
263
+ leadingDone += 1;
264
+ }
265
+ collapsedDone = Math.min(over, leadingDone);
266
+ }
267
+ if (collapsedDone > 0) {
268
+ lines.push(line(span(' ✔ ', { color: 'green' }), span(`${collapsedDone} completed`, { color: 'gray', dim: true })));
269
+ }
270
+ const remaining = items.slice(collapsedDone);
271
+ const budget = itemRowBudget - (collapsedDone > 0 ? 1 : 0);
272
+ const visible = remaining.length > budget ? remaining.slice(0, budget - 1) : remaining;
273
+ for (const item of visible) {
274
+ lines.push(todoItemLine(item, width));
275
+ }
276
+ if (remaining.length > visible.length) {
277
+ lines.push(plainLine(` … +${remaining.length - visible.length} more`, {
278
+ color: 'gray',
279
+ dim: true,
280
+ }));
281
+ }
282
+ return lines;
283
+ }
233
284
  export function renderTranscriptEntryLines(entry, width) {
234
285
  const color = getEntryColor(entry.kind);
235
286
  const lines = [
@@ -241,11 +292,14 @@ export function renderTranscriptEntryLines(entry, width) {
241
292
  if (entry.diffPreview) {
242
293
  lines.push(plainLine(` Added ${entry.diffPreview.added} line${entry.diffPreview.added === 1 ? '' : 's'}, removed ${entry.diffPreview.removed} line${entry.diffPreview.removed === 1 ? '' : 's'}`, { color: 'gray' }));
243
294
  for (const diffLine of entry.diffPreview.lines.slice(0, TRANSCRIPT_DIFF_PREVIEW_LINES)) {
244
- lines.push(line(span(`${diffLinePrefix(diffLine.kind)} `, { color: diffLineColor(diffLine.kind) }), span(fitDiffLine(diffLine.content || ' ', width - 4), {
295
+ lines.push(line(span(`${diffLinePrefix(diffLine.kind)} `, { color: diffLineColor(diffLine.kind) }), span(fitLine(diffLine.content || ' ', width - 4), {
245
296
  color: diffLineColor(diffLine.kind),
246
297
  })));
247
298
  }
248
299
  }
300
+ if (entry.todoList && entry.todoList.length > 0) {
301
+ lines.push(...renderTodoListLines(entry.todoList, width));
302
+ }
249
303
  return lines;
250
304
  }
251
305
  function tokenUsageLines(usage) {
@@ -298,6 +352,23 @@ function backgroundJobIndicatorSpans(state) {
298
352
  span(' · /jobs', { color: 'gray', dim: true }),
299
353
  ];
300
354
  }
355
+ function todoIndicatorSpans(state) {
356
+ if (state.busy)
357
+ return [];
358
+ const items = state.todos ?? [];
359
+ if (items.length === 0)
360
+ return [];
361
+ const done = items.filter((item) => item.status === 'completed').length;
362
+ if (done >= items.length)
363
+ return [];
364
+ return [
365
+ span(' ', { color: 'gray', dim: true }),
366
+ span(`◐ ${done}/${items.length} to-dos`, {
367
+ color: TODO_IN_PROGRESS_COLOR,
368
+ bold: true,
369
+ }),
370
+ ];
371
+ }
301
372
  function composerFooterLines(state) {
302
373
  const visibleSessionId = formatPromptSessionIdLabel(state.showSessionId, state.sessionId);
303
374
  const transientStatus = footerTransientStatus(state.status);
@@ -316,7 +387,7 @@ function composerFooterLines(state) {
316
387
  ]
317
388
  : []), ...(visibleSessionId
318
389
  ? [span(` ${visibleSessionId}`, { color: 'gray', dim: true })]
319
- : []), ...backgroundJobIndicatorSpans(state)),
390
+ : []), ...backgroundJobIndicatorSpans(state), ...todoIndicatorSpans(state)),
320
391
  plainLine(''),
321
392
  plainLine(formatModelLabel(state.currentModelId, state.serverModels), {
322
393
  color: 'cyan',
@@ -432,6 +503,16 @@ function buildJobsPickerLines(state, width, nowMs) {
432
503
  }));
433
504
  return lines;
434
505
  }
506
+ function thinkingHeaderLine(spinnerFrame, title, width) {
507
+ const spinner = `${BRAILLE_SPINNER_FRAMES[spinnerFrame % BRAILLE_SPINNER_FRAMES.length]} `;
508
+ const fittedTitle = title
509
+ ? fitLine(title, Math.max(8, width - spinner.length - 'Thinking'.length - 3))
510
+ : '';
511
+ return line(span(spinner, { color: 'green' }), span('Thinking', { color: 'green', bold: true }), ...(fittedTitle ? [span(` · ${fittedTitle}`, { color: 'ansi256(248)' })] : []));
512
+ }
513
+ function thinkingNoteLine(note, width) {
514
+ return line(span('│ ', { color: 'green' }), span(fitLine(note, Math.max(8, width - 3)), { color: 'ansi256(248)' }));
515
+ }
435
516
  function buildLiveLines(state, width, spinnerFrame, elapsedSeconds) {
436
517
  if (!state.busy)
437
518
  return [];
@@ -445,33 +526,44 @@ function buildLiveLines(state, width, spinnerFrame, elapsedSeconds) {
445
526
  }, width));
446
527
  lines.push(plainLine(''));
447
528
  }
448
- lines.push(plainLine(`${WORKING_CLOCK_ICON} Working · ${elapsedSeconds < 60 ? `${elapsedSeconds}s` : `${Math.floor(elapsedSeconds / 60)}m ${String(elapsedSeconds % 60).padStart(2, '0')}s`}`, { color: 'yellow' }));
449
- const visibleTitle = state.thinkingTitle.trim();
450
- const visibleNotes = state.thinkingNotes.filter(Boolean).slice(-THINKING_NOTE_PREVIEW_ROWS);
451
- if (visibleTitle || visibleNotes.length > 0) {
452
- lines.push(plainLine(''));
453
- lines.push(line(span(`${BRAILLE_SPINNER_FRAMES[spinnerFrame % BRAILLE_SPINNER_FRAMES.length]} `, {
454
- color: 'green',
455
- }), span('Thinking', { color: 'green', bold: true }), ...(visibleTitle ? [span(` · ${visibleTitle}`, { color: 'ansi256(248)' })] : [])));
456
- for (const note of visibleNotes) {
457
- lines.push(line(span('│ ', { color: 'green' }), span(note, { color: 'ansi256(248)' })));
458
- }
459
- }
460
529
  const toolEntries = state.workingTools.slice(-WORKING_TOOL_PREVIEW_ROWS);
461
530
  if (toolEntries.length > 0) {
462
- lines.push(plainLine(''));
463
531
  for (const entry of toolEntries) {
464
532
  const color = getEntryColor(entry.kind);
465
533
  const body = entry.body.split('\n')[0]?.trim();
466
534
  lines.push(line(span('● ', { color }), span(entry.title, { color, bold: true }), ...(body ? [span(` ${truncate(body, 140)}`, { color })] : [])));
467
535
  }
536
+ lines.push(plainLine(''));
468
537
  }
469
538
  const logLines = state.commandLog.slice(-COMMAND_PREVIEW_LINES);
470
539
  if (logLines.length > 0) {
471
- lines.push(plainLine(''));
540
+ lines.push(plainLine('⋮ output', { color: 'gray', dim: true }));
472
541
  for (const outputLine of logLines) {
473
542
  lines.push(plainLine(outputLine, { color: 'gray', dim: true }));
474
543
  }
544
+ lines.push(plainLine(''));
545
+ }
546
+ lines.push(plainLine(`${WORKING_CLOCK_ICON} Working · ${elapsedSeconds < 60 ? `${elapsedSeconds}s` : `${Math.floor(elapsedSeconds / 60)}m ${String(elapsedSeconds % 60).padStart(2, '0')}s`}`, { color: 'yellow' }));
547
+ if (state.todos.length > 0) {
548
+ lines.push(plainLine(''));
549
+ lines.push(...renderTodoListLines(state.todos, width, { header: true }));
550
+ }
551
+ const visibleTitle = state.thinkingTitle.trim();
552
+ const visibleNotes = state.thinkingNotes.filter(Boolean);
553
+ if (visibleTitle || visibleNotes.length > 0) {
554
+ lines.push(plainLine(''));
555
+ if (state.todos.length > 0) {
556
+ const minimalText = visibleTitle && visibleTitle !== 'Thinking'
557
+ ? visibleTitle
558
+ : (visibleNotes[visibleNotes.length - 1] ?? '');
559
+ lines.push(thinkingHeaderLine(spinnerFrame, minimalText, width));
560
+ }
561
+ else {
562
+ lines.push(thinkingHeaderLine(spinnerFrame, visibleTitle, width));
563
+ for (const note of visibleNotes.slice(-THINKING_NOTE_PREVIEW_ROWS)) {
564
+ lines.push(thinkingNoteLine(note, width));
565
+ }
566
+ }
475
567
  }
476
568
  lines.push(plainLine(''));
477
569
  return lines;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thegitai/cli",
3
- "version": "1.0.0-beta.19",
3
+ "version": "1.0.0-beta.20",
4
4
  "description": "TheGitAI CLI client (source-visible, proprietary)",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "homepage": "https://thegit.ai",
@@ -25,10 +25,10 @@
25
25
  "@lydell/node-pty-linux-x64": "1.1.0",
26
26
  "@lydell/node-pty-win32-arm64": "1.1.0",
27
27
  "@lydell/node-pty-win32-x64": "1.1.0",
28
- "@thegitai/tui-darwin-arm64": "1.0.0-beta.19",
29
- "@thegitai/tui-darwin-x64": "1.0.0-beta.19",
30
- "@thegitai/tui-linux-x64": "1.0.0-beta.19",
31
- "@thegitai/tui-win32-x64": "1.0.0-beta.19",
28
+ "@thegitai/tui-darwin-arm64": "1.0.0-beta.20",
29
+ "@thegitai/tui-darwin-x64": "1.0.0-beta.20",
30
+ "@thegitai/tui-linux-x64": "1.0.0-beta.20",
31
+ "@thegitai/tui-win32-x64": "1.0.0-beta.20",
32
32
  "@vscode/ripgrep": "1.18.0"
33
33
  },
34
34
  "publishConfig": {