@gajae-code/coding-agent 0.7.11 → 0.8.1

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.
Files changed (86) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +1 -1
  3. package/dist/types/capability/mcp.d.ts +2 -0
  4. package/dist/types/config/settings-schema.d.ts +25 -2
  5. package/dist/types/discovery/cursor.d.ts +4 -1
  6. package/dist/types/discovery/index.d.ts +0 -1
  7. package/dist/types/discovery/windsurf.d.ts +3 -1
  8. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +5 -0
  9. package/dist/types/modes/components/custom-editor.d.ts +4 -0
  10. package/dist/types/modes/components/settings-selector.d.ts +1 -0
  11. package/dist/types/modes/components/status-line.d.ts +8 -0
  12. package/dist/types/modes/components/two-column-body.d.ts +9 -0
  13. package/dist/types/modes/components/welcome.d.ts +9 -1
  14. package/dist/types/modes/controllers/input-controller.d.ts +1 -1
  15. package/dist/types/modes/controllers/selector-controller.d.ts +8 -0
  16. package/dist/types/modes/interactive-mode.d.ts +1 -0
  17. package/dist/types/modes/rpc/rpc-types.d.ts +1 -0
  18. package/dist/types/modes/types.d.ts +1 -0
  19. package/dist/types/notifications/telegram-daemon.d.ts +3 -0
  20. package/dist/types/runtime-mcp/config-writer.d.ts +9 -0
  21. package/dist/types/runtime-mcp/config.d.ts +2 -0
  22. package/dist/types/runtime-mcp/loader.d.ts +2 -0
  23. package/dist/types/runtime-mcp/manager.d.ts +2 -0
  24. package/dist/types/runtime-mcp/types.d.ts +6 -0
  25. package/dist/types/system-prompt.d.ts +7 -0
  26. package/dist/types/tools/skill.d.ts +1 -1
  27. package/dist/types/workflow/workflow-intent-diff.d.ts +19 -0
  28. package/package.json +10 -10
  29. package/src/capability/mcp.ts +2 -0
  30. package/src/config/mcp-schema.json +5 -1
  31. package/src/config/settings-schema.ts +18 -2
  32. package/src/coordinator-mcp/server.ts +39 -4
  33. package/src/discovery/builtin.ts +12 -0
  34. package/src/discovery/cursor.ts +5 -84
  35. package/src/discovery/gemini.ts +3 -89
  36. package/src/discovery/index.ts +4 -1
  37. package/src/discovery/mcp-json.ts +11 -0
  38. package/src/discovery/opencode.ts +5 -122
  39. package/src/discovery/windsurf.ts +4 -82
  40. package/src/edit/read-file.ts +9 -1
  41. package/src/extensibility/skills.ts +30 -2
  42. package/src/gjc-runtime/session-state-sidecar.ts +167 -24
  43. package/src/gjc-runtime/team-runtime.ts +56 -51
  44. package/src/internal-urls/docs-index.generated.ts +4 -4
  45. package/src/modes/acp/acp-agent.ts +7 -6
  46. package/src/modes/components/agent-dashboard.ts +1 -34
  47. package/src/modes/components/custom-editor.ts +18 -0
  48. package/src/modes/components/extensions/extension-dashboard.ts +2 -48
  49. package/src/modes/components/model-selector.ts +58 -2
  50. package/src/modes/components/session-selector.ts +19 -9
  51. package/src/modes/components/settings-selector.ts +13 -0
  52. package/src/modes/components/status-line.ts +215 -86
  53. package/src/modes/components/tree-selector.ts +28 -8
  54. package/src/modes/components/two-column-body.ts +36 -0
  55. package/src/modes/components/welcome.ts +171 -35
  56. package/src/modes/controllers/command-controller.ts +3 -2
  57. package/src/modes/controllers/event-controller.ts +4 -0
  58. package/src/modes/controllers/input-controller.ts +158 -38
  59. package/src/modes/controllers/selector-controller.ts +25 -28
  60. package/src/modes/interactive-mode.ts +20 -23
  61. package/src/modes/prompt-action-autocomplete.ts +17 -7
  62. package/src/modes/rpc/rpc-types.ts +1 -0
  63. package/src/modes/shared/agent-wire/deep-interview-gate.ts +9 -3
  64. package/src/modes/shared/agent-wire/workflow-gate-schema.ts +20 -0
  65. package/src/modes/types.ts +1 -0
  66. package/src/notifications/index.ts +20 -1
  67. package/src/notifications/lifecycle-control-runtime.ts +12 -3
  68. package/src/notifications/telegram-daemon.ts +14 -0
  69. package/src/prompts/system/system-prompt.md +15 -1
  70. package/src/runtime-mcp/config-writer.ts +28 -0
  71. package/src/runtime-mcp/config.ts +7 -0
  72. package/src/runtime-mcp/loader.ts +3 -0
  73. package/src/runtime-mcp/manager.ts +3 -0
  74. package/src/runtime-mcp/types.ts +6 -0
  75. package/src/sdk.ts +4 -0
  76. package/src/session/agent-session.ts +21 -1
  77. package/src/session/session-manager.ts +58 -18
  78. package/src/system-prompt.ts +8 -0
  79. package/src/task/executor.ts +5 -0
  80. package/src/tools/ast-grep.ts +10 -7
  81. package/src/tools/bash.ts +15 -6
  82. package/src/tools/index.ts +8 -1
  83. package/src/tools/skill.ts +1 -1
  84. package/src/workflow/workflow-intent-diff.ts +199 -0
  85. package/dist/types/discovery/vscode.d.ts +0 -1
  86. package/src/discovery/vscode.ts +0 -105
@@ -26,6 +26,7 @@ import { getPreset } from "./status-line/presets";
26
26
  import { renderSegment, type SegmentContext } from "./status-line/segments";
27
27
  import { getSeparator } from "./status-line/separators";
28
28
  import { calculateTokensPerSecond } from "./status-line/token-rate";
29
+ import type { SeparatorDef } from "./status-line/types";
29
30
 
30
31
  export interface StatusLineSegmentOptions {
31
32
  model?: { showThinkingLevel?: boolean };
@@ -45,12 +46,30 @@ export interface StatusLineSettings {
45
46
  showHookStatus?: boolean;
46
47
  showSkillHud?: boolean;
47
48
  sessionAccent?: boolean;
49
+ maxRows?: number;
48
50
  }
49
51
 
50
52
  export interface StatusLineComponentOptions {
51
53
  version?: string;
52
54
  }
53
55
 
56
+ interface CollectedStatusSegments {
57
+ ctx: SegmentContext;
58
+ separatorDef: SeparatorDef;
59
+ bgAnsi: string;
60
+ fgAnsi: string;
61
+ sepAnsi: string;
62
+ left: string[];
63
+ leftSegIds: StatusLineSegmentId[];
64
+ right: string[];
65
+ previewHighlightSegment: StatusLineSegmentId | undefined;
66
+ sessionAccent: boolean | undefined;
67
+ leftSepWidth: number;
68
+ rightSepWidth: number;
69
+ leftCapWidth: number;
70
+ rightCapWidth: number;
71
+ }
72
+
54
73
  // ═══════════════════════════════════════════════════════════════════════════
55
74
  // Per-message token cache
56
75
  // ═══════════════════════════════════════════════════════════════════════════
@@ -215,6 +234,7 @@ export class StatusLineComponent implements Component {
215
234
  showSkillHud: settings.get("statusLine.showSkillHud"),
216
235
  segmentOptions: settings.getGroup("statusLine").segmentOptions,
217
236
  sessionAccent: settings.get("statusLine.sessionAccent"),
237
+ maxRows: settings.get("statusLine.maxRows"),
218
238
  };
219
239
  this.#version = options.version?.trim() || undefined;
220
240
  }
@@ -702,7 +722,70 @@ export class StatusLineComponent implements Component {
702
722
  };
703
723
  }
704
724
 
705
- #buildStatusLine(width: number): string {
725
+ #groupWidth(parts: string[], capWidth: number, sepWidth: number): number {
726
+ if (parts.length === 0) return 0;
727
+ const partsWidth = parts.reduce((sum, part) => sum + visibleWidth(part), 0);
728
+ const sepTotal = Math.max(0, parts.length - 1) * (sepWidth + 2);
729
+ return partsWidth + sepTotal + 2 + capWidth;
730
+ }
731
+
732
+ #renderStatusGroup(
733
+ parts: string[],
734
+ direction: "left" | "right",
735
+ separatorDef: SeparatorDef,
736
+ bgAnsi: string,
737
+ fgAnsi: string,
738
+ sepAnsi: string,
739
+ ): string {
740
+ if (parts.length === 0) return "";
741
+ const sep = direction === "left" ? separatorDef.left : separatorDef.right;
742
+ const cap = separatorDef.endCaps
743
+ ? direction === "left"
744
+ ? separatorDef.endCaps.right
745
+ : separatorDef.endCaps.left
746
+ : "";
747
+ const capPrefix = separatorDef.endCaps?.useBgAsFg ? bgAnsi.replace("\x1b[48;", "\x1b[38;") : bgAnsi + sepAnsi;
748
+ const capText = cap ? `${capPrefix}${cap}\x1b[0m` : "";
749
+
750
+ let content = bgAnsi + fgAnsi;
751
+ content += ` ${parts.join(` ${sepAnsi}${sep}${fgAnsi} `)} `;
752
+ content += "\x1b[0m";
753
+
754
+ if (capText) {
755
+ return direction === "right" ? capText + content : content + capText;
756
+ }
757
+ return content;
758
+ }
759
+
760
+ #shrinkPathToWidth(content: string, ctx: SegmentContext, shrinkBy: number): string | null {
761
+ const currentPathVW = visibleWidth(content);
762
+ const minPathVW = 8; // icon + ellipsis + a few chars
763
+ const shrinkable = currentPathVW - minPathVW;
764
+ if (shrinkable <= 0 || shrinkBy <= 0) return null;
765
+ const targetShrink = Math.min(shrinkable, shrinkBy);
766
+ const currentMaxLen = ctx.options.path?.maxLength ?? 40;
767
+ let newMaxLen = Math.max(4, Math.min(currentMaxLen, currentPathVW) - targetShrink);
768
+ const pathCtx = (maxLen: number): SegmentContext => ({
769
+ ...ctx,
770
+ options: { ...ctx.options, path: { ...ctx.options.path, maxLength: maxLen } },
771
+ });
772
+ let reRendered = renderSegment("path", pathCtx(newMaxLen));
773
+ if (!reRendered.visible || !reRendered.content) return null;
774
+ // maxLength governs path text, not icon prefix; iterate to compensate.
775
+ for (let i = 0; i < 8; i++) {
776
+ const saved = currentPathVW - visibleWidth(reRendered.content);
777
+ if (saved >= targetShrink) break;
778
+ const nextMaxLen = Math.max(4, newMaxLen - (targetShrink - saved));
779
+ if (nextMaxLen >= newMaxLen) break; // no progress or hit floor
780
+ newMaxLen = nextMaxLen;
781
+ const adjusted = renderSegment("path", pathCtx(newMaxLen));
782
+ if (!adjusted.visible || !adjusted.content) break;
783
+ reRendered = adjusted;
784
+ }
785
+ return reRendered.content;
786
+ }
787
+
788
+ #collectStatusSegments(width: number): CollectedStatusSegments {
706
789
  const ctx = this.#buildSegmentContext(width);
707
790
  const effectiveSettings = this.#resolveSettings();
708
791
  const separatorDef = getSeparator(effectiveSettings.separator ?? "powerline-thin", theme);
@@ -715,25 +798,25 @@ export class StatusLineComponent implements Component {
715
798
  const fgAnsi = theme.getFgAnsi("text");
716
799
  const sepAnsi = theme.getFgAnsi("statusLineSep");
717
800
 
718
- // Collect visible segment contents
801
+ const previewHighlightSegment = effectiveSettings.previewHighlightSegment;
719
802
  const highlightSegment = (segId: StatusLineSegmentId, content: string): string =>
720
- effectiveSettings.previewHighlightSegment === segId ? `\x1b[7m${content}\x1b[27m` : content;
803
+ previewHighlightSegment === segId ? `\x1b[7m${content}\x1b[27m` : content;
721
804
 
722
- const leftParts: string[] = [];
805
+ const left: string[] = [];
723
806
  const leftSegIds: StatusLineSegmentId[] = [];
724
807
  for (const segId of effectiveSettings.leftSegments) {
725
808
  const rendered = renderSegment(segId, ctx);
726
809
  if (rendered.visible && rendered.content) {
727
- leftParts.push(highlightSegment(segId, rendered.content));
810
+ left.push(highlightSegment(segId, rendered.content));
728
811
  leftSegIds.push(segId);
729
812
  }
730
813
  }
731
814
 
732
- const rightParts: string[] = [];
815
+ const right: string[] = [];
733
816
  for (const segId of effectiveSettings.rightSegments) {
734
817
  const rendered = renderSegment(segId, ctx);
735
818
  if (rendered.visible && rendered.content) {
736
- rightParts.push(highlightSegment(segId, rendered.content));
819
+ right.push(highlightSegment(segId, rendered.content));
737
820
  }
738
821
  }
739
822
 
@@ -742,116 +825,152 @@ export class StatusLineComponent implements Component {
742
825
  if (runningBackgroundJobs > 0) {
743
826
  const icon = theme.icon.agents ? `${theme.icon.agents} ` : "";
744
827
  const label = `${formatCount("job", runningBackgroundJobs)} running`;
745
- rightParts.push(theme.fg("statusLineSubagents", `${icon}${label}`));
828
+ right.push(theme.fg("statusLineSubagents", `${icon}${label}`));
746
829
  }
747
830
  if (this.#version) {
748
- rightParts.push(theme.fg("dim", `v${this.#version}`));
831
+ right.push(theme.fg("dim", `v${this.#version}`));
749
832
  }
750
- const topFillWidth = Math.max(0, width);
751
- const left = [...leftParts];
752
- const right = [...rightParts];
753
-
754
- const leftSepWidth = visibleWidth(separatorDef.left);
755
- const rightSepWidth = visibleWidth(separatorDef.right);
756
- const leftCapWidth = separatorDef.endCaps ? visibleWidth(separatorDef.endCaps.right) : 0;
757
- const rightCapWidth = separatorDef.endCaps ? visibleWidth(separatorDef.endCaps.left) : 0;
758
-
759
- const groupWidth = (parts: string[], capWidth: number, sepWidth: number): number => {
760
- if (parts.length === 0) return 0;
761
- const partsWidth = parts.reduce((sum, part) => sum + visibleWidth(part), 0);
762
- const sepTotal = Math.max(0, parts.length - 1) * (sepWidth + 2);
763
- return partsWidth + sepTotal + 2 + capWidth;
833
+
834
+ return {
835
+ ctx,
836
+ separatorDef,
837
+ bgAnsi,
838
+ fgAnsi,
839
+ sepAnsi,
840
+ left,
841
+ leftSegIds,
842
+ right,
843
+ previewHighlightSegment,
844
+ sessionAccent: effectiveSettings.sessionAccent,
845
+ leftSepWidth: visibleWidth(separatorDef.left),
846
+ rightSepWidth: visibleWidth(separatorDef.right),
847
+ leftCapWidth: separatorDef.endCaps ? visibleWidth(separatorDef.endCaps.right) : 0,
848
+ rightCapWidth: separatorDef.endCaps ? visibleWidth(separatorDef.endCaps.left) : 0,
764
849
  };
850
+ }
851
+
852
+ #resolveMaxRows(): number {
853
+ const raw = this.#settings.maxRows ?? 1;
854
+ if (!Number.isFinite(raw)) return 1;
855
+ return Math.max(1, Math.min(3, Math.trunc(raw)));
856
+ }
765
857
 
766
- let leftWidth = groupWidth(left, leftCapWidth, leftSepWidth);
767
- let rightWidth = groupWidth(right, rightCapWidth, rightSepWidth);
858
+ #buildStatusLine(width: number, precollected?: CollectedStatusSegments): string {
859
+ const seg = precollected ?? this.#collectStatusSegments(width);
860
+ const { ctx, separatorDef, bgAnsi, fgAnsi, sepAnsi, previewHighlightSegment } = seg;
861
+ const { leftSepWidth, rightSepWidth, leftCapWidth, rightCapWidth } = seg;
862
+ const left = [...seg.left];
863
+ const leftIds = [...seg.leftSegIds];
864
+ const right = [...seg.right];
865
+ const topFillWidth = Math.max(0, width);
866
+
867
+ let leftWidth = this.#groupWidth(left, leftCapWidth, leftSepWidth);
868
+ let rightWidth = this.#groupWidth(right, rightCapWidth, rightSepWidth);
768
869
  const totalWidth = () => leftWidth + rightWidth + (left.length > 0 && right.length > 0 ? 1 : 0);
769
870
 
770
871
  if (topFillWidth > 0) {
771
872
  while (totalWidth() > topFillWidth && right.length > 0) {
772
873
  right.pop();
773
- rightWidth = groupWidth(right, rightCapWidth, rightSepWidth);
874
+ rightWidth = this.#groupWidth(right, rightCapWidth, rightSepWidth);
774
875
  }
775
876
  // Shrink path before dropping left segments — path is the only elastic segment
776
- const pathIdx = leftSegIds.indexOf("path");
877
+ const pathIdx = leftIds.indexOf("path");
777
878
  if (pathIdx >= 0 && totalWidth() > topFillWidth) {
778
879
  const overflow = totalWidth() - topFillWidth;
779
- const currentPathVW = visibleWidth(left[pathIdx]);
780
- const minPathVW = 8; // icon + ellipsis + a few chars
781
- const shrinkable = currentPathVW - minPathVW;
782
- if (shrinkable > 0) {
783
- const shrinkBy = Math.min(shrinkable, overflow);
784
- const currentMaxLen = ctx.options.path?.maxLength ?? 40;
785
- let newMaxLen = Math.max(4, Math.min(currentMaxLen, currentPathVW) - shrinkBy);
786
- const pathCtx = (maxLen: number): SegmentContext => ({
787
- ...ctx,
788
- options: { ...ctx.options, path: { ...ctx.options.path, maxLength: maxLen } },
789
- });
790
- let reRendered = renderSegment("path", pathCtx(newMaxLen));
791
- if (reRendered.visible && reRendered.content) {
792
- // maxLength governs path text, not icon prefix; iterate to compensate
793
- for (let i = 0; i < 8; i++) {
794
- const saved = currentPathVW - visibleWidth(reRendered.content);
795
- if (saved >= shrinkBy) break;
796
- const nextMaxLen = Math.max(4, newMaxLen - (shrinkBy - saved));
797
- if (nextMaxLen >= newMaxLen) break; // no progress or hit floor
798
- newMaxLen = nextMaxLen;
799
- const adjusted = renderSegment("path", pathCtx(newMaxLen));
800
- if (!adjusted.visible || !adjusted.content) break;
801
- reRendered = adjusted;
802
- }
803
- left[pathIdx] = highlightSegment("path", reRendered.content);
804
- leftWidth = groupWidth(left, leftCapWidth, leftSepWidth);
805
- }
880
+ const shrunk = this.#shrinkPathToWidth(left[pathIdx], ctx, overflow);
881
+ if (shrunk !== null) {
882
+ left[pathIdx] = previewHighlightSegment === "path" ? `\x1b[7m${shrunk}\x1b[27m` : shrunk;
883
+ leftWidth = this.#groupWidth(left, leftCapWidth, leftSepWidth);
806
884
  }
807
885
  }
808
886
  while (totalWidth() > topFillWidth && left.length > 0) {
809
887
  left.pop();
810
- leftSegIds.pop();
811
- leftWidth = groupWidth(left, leftCapWidth, leftSepWidth);
888
+ leftIds.pop();
889
+ leftWidth = this.#groupWidth(left, leftCapWidth, leftSepWidth);
812
890
  }
813
891
  }
814
892
 
815
- const renderGroup = (parts: string[], direction: "left" | "right"): string => {
816
- if (parts.length === 0) return "";
817
- const sep = direction === "left" ? separatorDef.left : separatorDef.right;
818
- const cap = separatorDef.endCaps
819
- ? direction === "left"
820
- ? separatorDef.endCaps.right
821
- : separatorDef.endCaps.left
822
- : "";
823
- const capPrefix = separatorDef.endCaps?.useBgAsFg ? bgAnsi.replace("\x1b[48;", "\x1b[38;") : bgAnsi + sepAnsi;
824
- const capText = cap ? `${capPrefix}${cap}\x1b[0m` : "";
825
-
826
- let content = bgAnsi + fgAnsi;
827
- content += ` ${parts.join(` ${sepAnsi}${sep}${fgAnsi} `)} `;
828
- content += "\x1b[0m";
829
-
830
- if (capText) {
831
- return direction === "right" ? capText + content : content + capText;
832
- }
833
- return content;
834
- };
835
-
836
- const leftGroup = renderGroup(left, "left");
837
- const rightGroup = renderGroup(right, "right");
893
+ const leftGroup = this.#renderStatusGroup(left, "left", separatorDef, bgAnsi, fgAnsi, sepAnsi);
894
+ const rightGroup = this.#renderStatusGroup(right, "right", separatorDef, bgAnsi, fgAnsi, sepAnsi);
838
895
  if (!leftGroup && !rightGroup) return "";
839
896
 
840
897
  if (topFillWidth === 0 || left.length === 0 || right.length === 0) {
841
898
  return leftGroup + (leftGroup && rightGroup ? " " : "") + rightGroup;
842
899
  }
843
900
 
844
- leftWidth = groupWidth(left, leftCapWidth, leftSepWidth);
845
- rightWidth = groupWidth(right, rightCapWidth, rightSepWidth);
901
+ leftWidth = this.#groupWidth(left, leftCapWidth, leftSepWidth);
902
+ rightWidth = this.#groupWidth(right, rightCapWidth, rightSepWidth);
846
903
  const gapWidth = Math.max(1, topFillWidth - leftWidth - rightWidth);
847
- const sessionName =
848
- effectiveSettings.sessionAccent !== false ? this.session.sessionManager?.getSessionName() : undefined;
904
+ const sessionName = seg.sessionAccent !== false ? this.session.sessionManager?.getSessionName() : undefined;
849
905
  const accentHex = sessionName ? getSessionAccentHex(sessionName) : undefined;
850
906
  const gapColor = getSessionAccentAnsi(accentHex) ?? theme.getFgAnsi("border");
851
907
  const gapFill = `${gapColor}${theme.boxRound.horizontal.repeat(gapWidth)}\x1b[39m`;
852
908
  return leftGroup + gapFill + rightGroup;
853
909
  }
854
910
 
911
+ /**
912
+ * Multi-row status line. When `maxRows > 1` and the single-line layout would
913
+ * overflow, segments wrap onto additional left-justified rows instead of
914
+ * being dropped. Falls back to the polished justified single row whenever
915
+ * everything fits on one line.
916
+ */
917
+ #buildStatusRows(width: number, maxRows: number): string[] {
918
+ const seg = this.#collectStatusSegments(width);
919
+ if (seg.left.length === 0 && seg.right.length === 0) return [];
920
+
921
+ const topFillWidth = Math.max(1, width);
922
+ const leftWidth = this.#groupWidth(seg.left, seg.leftCapWidth, seg.leftSepWidth);
923
+ const rightWidth = this.#groupWidth(seg.right, seg.rightCapWidth, seg.rightSepWidth);
924
+ const gap = seg.left.length > 0 && seg.right.length > 0 ? 1 : 0;
925
+ const fitsSingleRow = leftWidth + rightWidth + gap <= topFillWidth;
926
+
927
+ if (maxRows <= 1 || fitsSingleRow) {
928
+ const single = this.#buildStatusLine(width, seg);
929
+ return single ? [single] : [];
930
+ }
931
+
932
+ // Ordered sequence: left segments first (in order), then right segments.
933
+ const items: { content: string; isPath: boolean }[] = [
934
+ ...seg.left.map((content, i) => ({ content, isPath: seg.leftSegIds[i] === "path" })),
935
+ ...seg.right.map(content => ({ content, isPath: false })),
936
+ ];
937
+
938
+ // Pre-shrink an over-wide path so it never blows out a row on its own.
939
+ for (const item of items) {
940
+ if (!item.isPath) continue;
941
+ const alone = this.#groupWidth([item.content], seg.leftCapWidth, seg.leftSepWidth);
942
+ if (alone > topFillWidth) {
943
+ const shrunk = this.#shrinkPathToWidth(item.content, seg.ctx, alone - topFillWidth);
944
+ if (shrunk !== null) item.content = shrunk;
945
+ }
946
+ }
947
+
948
+ // Greedy pack into up to `maxRows` left-justified rows.
949
+ const rows: string[][] = [];
950
+ let current: string[] = [];
951
+ for (const item of items) {
952
+ if (current.length === 0) {
953
+ current.push(item.content);
954
+ continue;
955
+ }
956
+ const tentative = [...current, item.content];
957
+ if (this.#groupWidth(tentative, seg.leftCapWidth, seg.leftSepWidth) <= topFillWidth) {
958
+ current = tentative;
959
+ } else {
960
+ rows.push(current);
961
+ current = [item.content];
962
+ if (rows.length >= maxRows) break;
963
+ }
964
+ }
965
+ if (rows.length < maxRows && current.length > 0) {
966
+ rows.push(current);
967
+ }
968
+
969
+ return rows.map(row =>
970
+ this.#renderStatusGroup(row, "left", seg.separatorDef, seg.bgAnsi, seg.fgAnsi, seg.sepAnsi),
971
+ );
972
+ }
973
+
855
974
  getTopBorder(width: number): { content: string; width: number } {
856
975
  const content = this.#buildStatusLine(width);
857
976
  return {
@@ -860,6 +979,16 @@ export class StatusLineComponent implements Component {
860
979
  };
861
980
  }
862
981
 
982
+ /**
983
+ * Multi-row-aware content for the settings preview: the wrapped rows joined
984
+ * with newlines so a single `Text` renders them stacked. Honors the current
985
+ * `maxRows`; identical to the single status line when `maxRows` is 1 or when
986
+ * everything fits on one row.
987
+ */
988
+ getPreviewContent(width: number): string {
989
+ return this.#buildStatusRows(width, this.#resolveMaxRows()).join("\n");
990
+ }
991
+
863
992
  render(width: number): string[] {
864
993
  const lines: string[] = [];
865
994
  this.#refreshSkillHudInBackground();
@@ -868,9 +997,9 @@ export class StatusLineComponent implements Component {
868
997
  lines.push(skillHud);
869
998
  }
870
999
 
871
- const statusLine = this.#buildStatusLine(width);
872
- if (statusLine) {
873
- lines.push(truncateToWidth(statusLine, width));
1000
+ const statusRows = this.#buildStatusRows(width, this.#resolveMaxRows());
1001
+ for (const statusRow of statusRows) {
1002
+ if (statusRow) lines.push(truncateToWidth(statusRow, width));
874
1003
  }
875
1004
 
876
1005
  const showHooks = this.#settings.showHookStatus ?? true;
@@ -135,6 +135,14 @@ class TreeList implements Component {
135
135
  return this.#filteredNodes.length - 1;
136
136
  }
137
137
 
138
+ #clampSelectedIndex(): void {
139
+ if (this.#filteredNodes.length === 0) {
140
+ this.#selectedIndex = 0;
141
+ return;
142
+ }
143
+ this.#selectedIndex = Math.max(0, Math.min(this.#selectedIndex, this.#filteredNodes.length - 1));
144
+ }
145
+
138
146
  #flattenTree(roots: SessionTreeNode[]): FlatNode[] {
139
147
  const result: FlatNode[] = [];
140
148
  this.#toolCallMap.clear();
@@ -333,12 +341,11 @@ class TreeList implements Component {
333
341
  return true;
334
342
  });
335
343
 
336
- // Try to preserve cursor on the same node, or find nearest visible ancestor
344
+ // Try to preserve cursor on the same node, or keep the index in range.
337
345
  if (this.#lastSelectedId) {
338
346
  this.#selectedIndex = this.#findNearestVisibleIndex(this.#lastSelectedId);
339
- } else if (this.#selectedIndex >= this.#filteredNodes.length) {
340
- // Clamp index if out of bounds
341
- this.#selectedIndex = Math.max(0, this.#filteredNodes.length - 1);
347
+ } else {
348
+ this.#clampSelectedIndex();
342
349
  }
343
350
 
344
351
  // Update lastSelectedId to the actual selection (may have changed due to parent walk)
@@ -710,16 +717,29 @@ class TreeList implements Component {
710
717
  }
711
718
 
712
719
  handleInput(keyData: string): void {
720
+ const moveSelection = (delta: number, wrap: boolean): void => {
721
+ if (this.#filteredNodes.length === 0) return;
722
+ if (wrap) {
723
+ const next = this.#selectedIndex + delta;
724
+ this.#selectedIndex =
725
+ next < 0 ? this.#filteredNodes.length - 1 : next >= this.#filteredNodes.length ? 0 : next;
726
+ } else {
727
+ this.#selectedIndex += delta;
728
+ this.#clampSelectedIndex();
729
+ }
730
+ this.#lastSelectedId = this.#filteredNodes[this.#selectedIndex]?.node.entry.id ?? this.#lastSelectedId;
731
+ };
732
+
713
733
  if (matchesKey(keyData, "up")) {
714
- this.#selectedIndex = this.#selectedIndex === 0 ? this.#filteredNodes.length - 1 : this.#selectedIndex - 1;
734
+ moveSelection(-1, true);
715
735
  } else if (matchesKey(keyData, "down")) {
716
- this.#selectedIndex = this.#selectedIndex === this.#filteredNodes.length - 1 ? 0 : this.#selectedIndex + 1;
736
+ moveSelection(1, true);
717
737
  } else if (matchesKey(keyData, "left")) {
718
738
  // Page up
719
- this.#selectedIndex = Math.max(0, this.#selectedIndex - this.maxVisibleLines);
739
+ moveSelection(-this.maxVisibleLines, false);
720
740
  } else if (matchesKey(keyData, "right")) {
721
741
  // Page down
722
- this.#selectedIndex = Math.min(this.#filteredNodes.length - 1, this.#selectedIndex + this.maxVisibleLines);
742
+ moveSelection(this.maxVisibleLines, false);
723
743
  } else if (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") {
724
744
  const selected = this.#filteredNodes[this.#selectedIndex];
725
745
  if (selected && this.onSelect) {
@@ -0,0 +1,36 @@
1
+ import { type Component, padding, truncateToWidth, visibleWidth } from "@gajae-code/tui";
2
+ import { theme } from "../theme/theme";
3
+
4
+ export class TwoColumnBody implements Component {
5
+ constructor(
6
+ private readonly leftPane: Component,
7
+ private readonly rightPane: Component,
8
+ private readonly maxHeight: number,
9
+ ) {}
10
+
11
+ render(width: number): string[] {
12
+ const leftWidth = Math.floor(width * 0.5);
13
+ const separatorText = theme.fg("dim", ` ${theme.boxSharp.vertical} `);
14
+ const separatorWidth = width - leftWidth > 3 ? visibleWidth(separatorText) : 0;
15
+ const separator = separatorWidth > 0 ? separatorText : "";
16
+ const rightWidth = Math.max(0, width - leftWidth - separatorWidth);
17
+ const leftLines = this.leftPane.render(leftWidth);
18
+ const rightLines = this.rightPane.render(rightWidth);
19
+ const lineCount = Math.min(this.maxHeight, Math.max(leftLines.length, rightLines.length));
20
+ const out: string[] = [];
21
+
22
+ for (let i = 0; i < lineCount; i++) {
23
+ const left = truncateToWidth(leftLines[i] ?? "", leftWidth);
24
+ const leftPadded = left + padding(Math.max(0, leftWidth - visibleWidth(left)));
25
+ const right = truncateToWidth(rightLines[i] ?? "", rightWidth);
26
+ out.push(leftPadded + separator + right);
27
+ }
28
+
29
+ return out;
30
+ }
31
+
32
+ invalidate(): void {
33
+ this.leftPane.invalidate?.();
34
+ this.rightPane.invalidate?.();
35
+ }
36
+ }