Skip to content

Commit 560c82f

Browse files
committed
fix(tui): drop the preamble every Dynamic Workflow task repeats
`prompt_template` is optional, so a caller may pass a whole prompt as each item. Every agent row then opened with the same paragraph and the task column clipped inside it, leaving six rows that named nothing. Measure the shared head across every member, cut it at the last shared word boundary, and mark the elision with a single column. The elision is all-or-nothing and skipped for a short head, so a column never means two different things and a mark never costs more than it frees.
1 parent dba0dce commit 560c82f

4 files changed

Lines changed: 163 additions & 4 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@pythoughts/pythinker-code": patch
3+
---
4+
5+
Drop the preamble every Dynamic Workflow task repeats so each agent row shows the part that names it.

apps/pythinker-code/src/tui/components/messages/dynamic-workflow-mission-control.ts

Lines changed: 65 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import { shimmerText } from '#/tui/utils/shimmer';
1111
const RESUMED_ITEM_LABEL = '(resumed)';
1212
/** Divider between the cells that share a member row's free space. */
1313
const MEMBER_SEPARATOR = ' · ';
14+
/** Marks a task cell whose shared preamble was dropped. One column wide. */
15+
const TASK_ELISION_MARK = '…';
1416
const ORCHESTRATING_LABEL = 'Orchestrating';
1517
const FINALIZING_LABEL = 'Finalizing';
1618
// Pad to the wider live label so the suffix column never shifts between them.
@@ -440,8 +442,11 @@ export class DynamicWorkflowMissionControlComponent implements Component {
440442
const needsMore = members.length > slots;
441443
const memberSlots = needsMore && slots >= 2 ? slots - 1 : slots;
442444
const visibleMembers = members.slice(0, Math.max(0, memberSlots));
445+
// Measured across every member, not the visible ones: a prefix that came
446+
// and went as rows scrolled would rewrite the task column under the eye.
447+
const sharedPrefix = sharedTaskPrefix(members);
443448
for (const member of visibleMembers) {
444-
lines.push(this.renderMember(member, width, nowMs));
449+
lines.push(this.renderMember(member, width, nowMs, sharedPrefix));
445450
}
446451
const hidden = members.length - visibleMembers.length;
447452
if (hidden > 0 && lines.length < rowBudget) {
@@ -567,7 +572,12 @@ export class DynamicWorkflowMissionControlComponent implements Component {
567572
return truncateToWidth(currentTheme.fg('textDim', header), width);
568573
}
569574

570-
private renderMember(member: DynamicWorkflowMember, width: number, nowMs: number): string {
575+
private renderMember(
576+
member: DynamicWorkflowMember,
577+
width: number,
578+
nowMs: number,
579+
sharedPrefix: string,
580+
): string {
571581
const id = currentTheme.fg('primary', String(member.index).padStart(3, '0'));
572582
// All running rows share the workflow's clock, so they spin in step instead
573583
// of drifting apart by whenever each agent happened to start.
@@ -585,6 +595,11 @@ export class DynamicWorkflowMissionControlComponent implements Component {
585595
? `${id} ${workColumn} ${stateColumn} `
586596
: `${id} ${padToWidth(state, 6)} `;
587597
const task = member.item || 'Delegated agent';
598+
// The elision is display-only: the dedup below still compares whole items,
599+
// so a streamed line that merely repeats the task is still suppressed.
600+
const shownTask = sharedPrefix.length > 0 && member.item.startsWith(sharedPrefix)
601+
? `${TASK_ELISION_MARK}${member.item.slice(sharedPrefix.length)}`
602+
: task;
588603
const latest = member.latest.length > 0 && member.latest !== task ? member.latest : undefined;
589604
const detail = member.phase === 'suspended' || isTerminalPhase(member.phase)
590605
? member.statusDetail ?? latest
@@ -613,15 +628,15 @@ export class DynamicWorkflowMissionControlComponent implements Component {
613628
Math.floor(rest * DYNAMIC_WORKFLOW_RENDERING.memberTaskShare),
614629
);
615630
const detailBudget = showWork && detail !== undefined && detail.length > 0
616-
? rest - Math.min(visibleWidth(task), taskCap) - MEMBER_SEPARATOR.length
631+
? rest - Math.min(visibleWidth(shownTask), taskCap) - MEMBER_SEPARATOR.length
617632
: 0;
618633
const detailPart = detailBudget >= DYNAMIC_WORKFLOW_RENDERING.memberDetailMinWidth
619634
? `${MEMBER_SEPARATOR}${truncateToWidth(currentTheme.fg('textDim', detail ?? ''), detailBudget)}`
620635
: '';
621636

622637
// Whatever the detail did not take goes back to the task.
623638
const taskText = truncateToWidth(
624-
currentTheme.fg('text', task),
639+
currentTheme.fg('text', shownTask),
625640
Math.max(1, rest - visibleWidth(detailPart)),
626641
);
627642
return truncateToWidth(
@@ -1071,6 +1086,52 @@ function normalizeText(text: string | undefined): string {
10711086
return text?.replaceAll(/\s+/g, ' ').trim() ?? '';
10721087
}
10731088

1089+
/**
1090+
* The preamble every task repeats, or `''` when dropping it would not help.
1091+
*
1092+
* `prompt_template` is optional, so a caller may pass a whole prompt as each
1093+
* item. Every row then opens with the same paragraph and the TASK column clips
1094+
* inside it — six rows reading `You are auditing the pythinker-code mono...`
1095+
* name nothing. Dropping the shared head once puts the tail that identifies the
1096+
* row back on screen.
1097+
*
1098+
* All-or-nothing on purpose: eliding a prefix that only some rows carry would
1099+
* make two cells at the same column mean different things.
1100+
*/
1101+
function sharedTaskPrefix(members: readonly DynamicWorkflowMember[]): string {
1102+
const items = members.map((member) => member.item).filter((item) => item.length > 0);
1103+
const first = items[0];
1104+
if (first === undefined || items.length < 2) return '';
1105+
1106+
// Skips `first` against itself: that comparison can only return its own
1107+
// length, and it walks the whole string to say so on every animation frame.
1108+
let length = first.length;
1109+
for (const item of items.slice(1)) {
1110+
length = commonPrefixLength(first, item, length);
1111+
if (length === 0) return '';
1112+
}
1113+
1114+
// Cut at the last space inside the shared text. A cut mid-word reads as
1115+
// corruption, and a space is always a whole code unit, so ending there is
1116+
// also what keeps the slice off the middle of a surrogate pair.
1117+
//
1118+
// Backing off to before the last shared word is what leaves every row
1119+
// something after the mark: items are normalized, so none of them ends in a
1120+
// space, and the shortest one therefore still holds the word the cut skipped.
1121+
const boundary = first.lastIndexOf(' ', length - 1);
1122+
if (boundary < 0) return '';
1123+
const prefix = first.slice(0, boundary + 1);
1124+
if (visibleWidth(prefix) < DYNAMIC_WORKFLOW_RENDERING.memberTaskSharedPrefixMinWidth) return '';
1125+
return prefix;
1126+
}
1127+
1128+
function commonPrefixLength(left: string, right: string, limit: number): number {
1129+
const bound = Math.min(limit, left.length, right.length);
1130+
let index = 0;
1131+
while (index < bound && left[index] === right[index]) index += 1;
1132+
return index;
1133+
}
1134+
10741135
/**
10751136
* The WORK cell: tool calls done, and how long this agent has been silent.
10761137
*

apps/pythinker-code/src/tui/constant/rendering.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,12 @@ export const DYNAMIC_WORKFLOW_RENDERING = {
3434
memberTaskShare: 0.6,
3535
/** Below this the detail is dropped: a few clipped characters say nothing. */
3636
memberDetailMinWidth: 8,
37+
/**
38+
* Least shared task prefix worth eliding. A short prefix costs about as much
39+
* to mark as it frees, so only a preamble long enough to have been clipping
40+
* the part that names the row is dropped.
41+
*/
42+
memberTaskSharedPrefixMinWidth: 16,
3743
/**
3844
* Upper bound on one buffered output line. A model may stream a single line
3945
* with no newline in it at all, so this is the only thing that stops the

apps/pythinker-code/test/tui/components/messages/dynamic-workflow-mission-control.test.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ function renderText(component: DynamicWorkflowMissionControlComponent, width = 1
2323
/** The STATE cell of a running row: a grey braille spinner frame, then the label. */
2424
const RUNNING_CELL = /[] RUN/u;
2525

26+
/** Head of a task cell that lost the preamble every row shared. */
27+
const TASK_ELISION_MARK = '…';
28+
2629
function memberLine(output: string, index: number): string {
2730
const id = String(index).padStart(3, '0');
2831
const line = output.split('\n').find(
@@ -772,6 +775,90 @@ describe('DynamicWorkflowMissionControlComponent', () => {
772775
expect(lines.join('\n')).not.toContain('Recent activity');
773776
});
774777

778+
it('drops the preamble every task repeats so the row keeps what names it', () => {
779+
const preamble = 'You are auditing the pythinker-code monorepo at /Users/panda. Verify ';
780+
const component = createComponent();
781+
component.updateArgs({
782+
items: [
783+
`${preamble}the permission glob`,
784+
`${preamble}the concurrency cap`,
785+
`${preamble}the resume path`,
786+
],
787+
});
788+
component.markInputComplete();
789+
790+
const output = renderText(component, 100);
791+
expect(output).not.toContain('You are auditing');
792+
// Greedy on purpose: the shared `the ` goes with the rest of the preamble.
793+
expect(memberLine(output, 1)).toContain('…permission glob');
794+
expect(memberLine(output, 2)).toContain('…concurrency cap');
795+
expect(memberLine(output, 3)).toContain('…resume path');
796+
797+
// The mark is one column wide, so it never pushes a row past the frame.
798+
for (const width of [20, 40, 63, 64, 79, 80, 100, 150]) {
799+
expect(component.render(width).every((line) => visibleWidth(line) <= width)).toBe(true);
800+
}
801+
});
802+
803+
it.each([
804+
// Nothing shared: every row already names itself.
805+
{ name: 'no shared head', items: ['Audit the plan', 'Ship the release'] },
806+
// Shared but short: the mark would cost about what the elision frees.
807+
{ name: 'a short shared head', items: ['Audit the plan', 'Audit the release'] },
808+
// One row is the whole of what the other shares, and what is left over is
809+
// one short word — below the floor, so the rows stay whole.
810+
{ name: 'a row that is the whole shared head', items: ['Audit the plan appendix', 'Audit the plan'] },
811+
// A prefix with no space in it can only be cut mid-word.
812+
{ name: 'an unbroken shared head', items: ['aaaaaaaaaaaaaaaaaaaa-one', 'aaaaaaaaaaaaaaaaaaaa-two'] },
813+
])('keeps whole tasks when there is $name', ({ items }) => {
814+
const component = createComponent();
815+
component.updateArgs({ items });
816+
component.markInputComplete();
817+
818+
const output = renderText(component, 200);
819+
items.forEach((item, index) => {
820+
expect(memberLine(output, index + 1)).toContain(item);
821+
expect(memberLine(output, index + 1)).not.toContain(TASK_ELISION_MARK);
822+
});
823+
});
824+
825+
it('leaves a row whose whole task is the shared head with the word the cut skipped', () => {
826+
const component = createComponent();
827+
component.updateArgs({
828+
items: [
829+
'Audit the pythinker-code monorepo',
830+
'Audit the pythinker-code monorepo plan',
831+
],
832+
});
833+
component.markInputComplete();
834+
835+
// The cut lands before `monorepo`, not after it, so the shorter row keeps a
836+
// word rather than collapsing to the mark on its own.
837+
const output = renderText(component, 100);
838+
expect(memberLine(output, 1)).toContain('…monorepo');
839+
expect(memberLine(output, 2)).toContain('…monorepo plan');
840+
expect(output).not.toContain('Audit the pythinker-code');
841+
});
842+
843+
it('holds the elision steady while rows are clipped away', () => {
844+
const preamble = 'Audit the pythinker-code monorepo and report on ';
845+
const items = ['the plan', 'the cap', 'the resume path', 'the glob'].map(
846+
(tail) => `${preamble}${tail}`,
847+
);
848+
const full = createComponent();
849+
full.updateArgs({ items });
850+
full.markInputComplete();
851+
// Two of the four rows are clipped, but the prefix is measured across every
852+
// member, so the visible rows read exactly as they did before the clip.
853+
const clipped = createComponent({ availableRows: () => 6 });
854+
clipped.updateArgs({ items });
855+
clipped.markInputComplete();
856+
857+
expect(memberLine(renderText(clipped, 100), 1))
858+
.toBe(memberLine(renderText(full, 100), 1));
859+
expect(memberLine(renderText(clipped, 100), 1)).toContain('…plan');
860+
});
861+
775862
it('keeps three workflow-relative activity entries with suspension and failure details', () => {
776863
vi.useFakeTimers();
777864
vi.setSystemTime(0);

0 commit comments

Comments
 (0)