pi-ask-popup 0.1.1 → 0.2.0

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.
@@ -9,6 +9,8 @@ import type {
9
9
  export const DECLINE_MESSAGE = "User declined to answer questions";
10
10
  export const TIMED_OUT_MESSAGE =
11
11
  "Questionnaire timed out — the user did not respond within the configured timeout. The user never saw a decline; do NOT treat this as a rejection. Ask the questions as plain chat text instead or retry.";
12
+ export const HOST_ERROR_MESSAGE =
13
+ "The host replied with a value that was never offered, so the questionnaire could not be completed. Nobody declined and nobody answered — do NOT treat this as a rejection. Ask the questions as plain chat text instead.";
12
14
  export const ENVELOPE_PREFIX = "User has answered your questions:";
13
15
  export const ENVELOPE_SUFFIX = "You can now continue with the user's answers in mind.";
14
16
  /** Opens the segment for a note whose question was never answered. */
@@ -51,6 +53,23 @@ export function buildQuestionnaireResponse(
51
53
  }
52
54
  return buildToolResult(TIMED_OUT_MESSAGE, details);
53
55
  }
56
+ if (result?.error === "host_error") {
57
+ // A broken host is not a decision. Sharing the decline text would tell the
58
+ // model the user said no, when the user was never shown a working dialog.
59
+ const details: QuestionnaireResult = {
60
+ answers: result.answers,
61
+ cancelled: true,
62
+ error: "host_error",
63
+ };
64
+ if (result.hostErrorDetail && result.hostErrorDetail.length > 0) {
65
+ (details as { hostErrorDetail: string }).hostErrorDetail = result.hostErrorDetail;
66
+ return buildToolResult(
67
+ `${HOST_ERROR_MESSAGE} (host sent: ${result.hostErrorDetail})`,
68
+ details,
69
+ );
70
+ }
71
+ return buildToolResult(HOST_ERROR_MESSAGE, details);
72
+ }
54
73
  if (!result || result.cancelled) {
55
74
  // The decline text stays canonical even when a global note rides a
56
75
  // cancelled result. The note survives in `details`, like partial answers.
@@ -71,11 +90,18 @@ export function buildQuestionnaireResponse(
71
90
  return buildToolResult(DECLINE_MESSAGE, details);
72
91
  }
73
92
 
93
+ // Indexed once rather than scanned per question. Both sides are keyed by
94
+ // `questionIndex`, which is what the loop below asks for, and it is the same
95
+ // shape `orderedAnswers` uses in the reducer. A first entry wins, so a
96
+ // duplicated index reads as the earlier `find` did.
97
+ const answerByIndex = byQuestionIndex(result.answers);
98
+ const noteByIndex = byQuestionIndex(result.unansweredNotes ?? []);
99
+
74
100
  const segments: string[] = [];
75
101
  // Iterate the questions rather than the answers so segments always follow the
76
102
  // order the model asked in, whatever order the user filled tabs.
77
103
  for (let i = 0; i < params.questions.length; i++) {
78
- const a = result.answers.find((x) => x.questionIndex === i);
104
+ const a = answerByIndex.get(i);
79
105
  if (a) {
80
106
  segments.push(buildAnswerSegment(a));
81
107
  continue;
@@ -84,7 +110,7 @@ export function buildQuestionnaireResponse(
84
110
  // emitted here rather than grouped at the end. Because this loop runs
85
111
  // before the "nothing to report" check below, a questionnaire submitted
86
112
  // with nothing but such a note counts as answered rather than declined.
87
- const n = result.unansweredNotes?.find((x) => x.questionIndex === i);
113
+ const n = noteByIndex.get(i);
88
114
  if (n) {
89
115
  segments.push(buildUnansweredNoteSegment(n));
90
116
  }
@@ -100,6 +126,17 @@ export function buildQuestionnaireResponse(
100
126
  return buildToolResult(`${ENVELOPE_PREFIX} ${segments.join(" ")} ${ENVELOPE_SUFFIX}`, result);
101
127
  }
102
128
 
129
+ /** First entry per `questionIndex` wins, which is what `Array.find` did. */
130
+ function byQuestionIndex<T extends { questionIndex: number }>(items: readonly T[]): Map<number, T> {
131
+ const out = new Map<number, T>();
132
+ for (const item of items) {
133
+ if (!out.has(item.questionIndex)) {
134
+ out.set(item.questionIndex, item);
135
+ }
136
+ }
137
+ return out;
138
+ }
139
+
103
140
  /**
104
141
  * One answer as an envelope segment: `"question"="answer"`, optionally followed
105
142
  * by the preview the user was looking at and the note they wrote.
package/src/tool/types.ts CHANGED
@@ -114,7 +114,10 @@ export type QuestionParams = Static<typeof QuestionParamsSchema>;
114
114
  * - `custom` — the user typed free text in the "Type something." row.
115
115
  * `answer` is the text, or null when they committed nothing.
116
116
  * - `multi` — the user committed multi-select choices. `selected` carries the
117
- * chosen labels and `answer` is null.
117
+ * chosen labels and `answer` is null. Text typed on the "Type something." row
118
+ * appears in `selected` too, as its own trimmed entry: on a multi-select
119
+ * question that row counts as chosen whenever it holds text, so an answer can
120
+ * be several authored labels plus one thing the user wrote.
118
121
  */
119
122
  export interface QuestionAnswer {
120
123
  questionIndex: number;
@@ -162,7 +165,8 @@ export type QuestionnaireError =
162
165
  | "reserved_label"
163
166
  | "session_load_failed"
164
167
  | "stale_module_cache"
165
- | "timed_out";
168
+ | "timed_out"
169
+ | "host_error";
166
170
 
167
171
  export interface QuestionnaireResult {
168
172
  answers: QuestionAnswer[];
@@ -192,6 +196,15 @@ export interface QuestionnaireResult {
192
196
  */
193
197
  unansweredNotes?: UnansweredNote[];
194
198
  error?: QuestionnaireError;
199
+ /**
200
+ * What the host actually sent, on a `host_error` result. Quoted into the
201
+ * envelope so whoever reads the transcript can see which value was rejected
202
+ * rather than guessing at the malfunction.
203
+ *
204
+ * Same conditional-spread contract as `globalNote`: present only alongside
205
+ * `error: "host_error"`, never assigned `undefined`.
206
+ */
207
+ hostErrorDetail?: string;
195
208
  }
196
209
 
197
210
  export function isQuestionnaireResult(value: unknown): value is QuestionnaireResult {
@@ -20,6 +20,13 @@ export const MULTI_SUBMIT_LABEL = "Submit";
20
20
  export interface MultiSelectOtherRowProps {
21
21
  /** The "Type something." row is the focused row (optionIndex === options.length). */
22
22
  active: boolean;
23
+ /**
24
+ * The typed text counts as a selection, which on this row means only that
25
+ * there is some. Drawn with the same ✔ as an option: the row is as chosen as
26
+ * a ticked box, and the box used to be painted permanently empty while the
27
+ * text under it was on its way to the model.
28
+ */
29
+ checked: boolean;
23
30
  /** `state.inputMode` — true once the row has focus and keystrokes append to the buffer. */
24
31
  inputMode: boolean;
25
32
  /** Live inline-input buffer (read from `runtime.inputBuffer` / `ctx.inputBuffer`). */
@@ -66,7 +73,13 @@ export class MultiSelectView implements StatefulView<MultiSelectViewProps> {
66
73
  ) {
67
74
  this.props = {
68
75
  rows: [],
69
- other: { active: false, inputMode: false, inputBuffer: "", inputCursorOffset: undefined },
76
+ other: {
77
+ active: false,
78
+ checked: false,
79
+ inputMode: false,
80
+ inputBuffer: "",
81
+ inputCursorOffset: undefined,
82
+ },
70
83
  nextActive: false,
71
84
  nextLabel: ROW_INTENT_META.next.label,
72
85
  };
@@ -175,7 +188,9 @@ export class MultiSelectView implements StatefulView<MultiSelectViewProps> {
175
188
  private renderOtherRow(contentWidth: number, numberWidth: number): string[] {
176
189
  const other = this.props.other;
177
190
  const pointer = other.active ? this.theme.fg("accent", ACTIVE_POINTER) : INACTIVE_POINTER;
178
- const box = this.theme.fg("muted", UNCHECKED);
191
+ const box = other.checked
192
+ ? this.theme.fg("accent", CHECKED)
193
+ : this.theme.fg("muted", UNCHECKED);
179
194
  const number = String(this.question.options.length + 1).padStart(numberWidth, " ");
180
195
  const rowPrefix = `${pointer}${number}${NUMBER_SEPARATOR}${box}${BOX_LABEL_GAP}`;
181
196
  const continuationPrefix = " ".repeat(visibleWidth(rowPrefix));
@@ -71,6 +71,11 @@ export class OptionListView implements StatefulView<OptionListViewProps> {
71
71
  return this.select.render(width);
72
72
  }
73
73
 
74
+ /** Row count at `width`, equal to `render(width).length`, without building the rows. */
75
+ measureHeight(width: number): number {
76
+ return this.select.measureHeight(width);
77
+ }
78
+
74
79
  focusedItemRowRange(width: number): [number, number] {
75
80
  return this.select.focusedItemRowRange(width);
76
81
  }
@@ -10,22 +10,39 @@ export const MAX_PREVIEW_HEIGHT_SIDE_BY_SIDE = 20;
10
10
  /** Preserves narrow-terminal protection in stacked layout. */
11
11
  export const MAX_PREVIEW_HEIGHT_STACKED = 15;
12
12
  export const NO_PREVIEW_TEXT = "No preview available";
13
+ /** Fallback body line when a preview's markdown fails to render (untrusted input boundary). */
14
+ export const PREVIEW_RENDER_FAILED_TEXT = "Preview failed to render";
13
15
  /** 1 blank separator + 1 affordance text row reserved when `hasAnyPreview` (height stability of the affordance row's offset relative to the box). */
14
16
  export const NOTES_AFFORDANCE_OVERHEAD = 2;
15
17
 
16
18
  /**
17
- * Per-question cache for rendered markdown previews. Width-keyed: switching the
18
- * inner width invalidates every cached `Markdown`'s render output (pi-tui's
19
- * `Markdown.render(width)` re-wraps when width changes).
19
+ * One entry per option that carries a preview. `md` is built once and kept for
20
+ * the life of the cache; `lines` are the stripped rows it last produced, and
21
+ * `width` is the inner width they were produced at.
22
+ */
23
+ interface PreviewEntry {
24
+ md: ReturnType<MarkdownFactory>;
25
+ width: number | undefined;
26
+ lines: string[] | undefined;
27
+ }
28
+
29
+ /**
30
+ * Per-question cache for rendered markdown previews, keyed per option.
31
+ *
32
+ * Width used to be tracked for the cache as a whole: any change invalidated
33
+ * every `Markdown` it held, so a resize drag re-wrapped all four options one by
34
+ * one through the `maxNaturalHeight` loop even though the frame only measured
35
+ * some of them. Each option now remembers the width it was rendered at, so a
36
+ * width flip costs a re-render of the options that frame actually asks for and
37
+ * nothing else. It also holds the stripped rows, which spares the second strip
38
+ * when `blockHeight` and `renderBlock` ask for the same option in one frame.
20
39
  *
21
- * Replaces the inline `previewTexts`, `markdownCache`, `cachedWidth` triple from
22
- * the previous monolithic `preview-pane.ts`. One Markdown per option, lazy on
23
- * first request, never re-constructed — count semantics frozen by tests.
40
+ * One Markdown per option, lazy on first request, never re-constructed — count
41
+ * semantics frozen by tests.
24
42
  */
25
43
  export class MarkdownContentCache {
26
44
  private readonly previewTexts: Map<number, string>;
27
- private readonly markdownCache: Map<number, ReturnType<MarkdownFactory>>;
28
- private cachedWidth: number | undefined;
45
+ private readonly markdownCache: Map<number, PreviewEntry>;
29
46
  private readonly theme: Theme;
30
47
  private readonly markdownTheme: MarkdownTheme;
31
48
  private readonly markdownFactory: MarkdownFactory;
@@ -58,34 +75,54 @@ export class MarkdownContentCache {
58
75
  }
59
76
 
60
77
  /**
61
- * Compute the body lines for a given option at a given inner width. Width changes
62
- * invalidate the per-Markdown render cache.
78
+ * Body lines for one option at one inner width. A repeat request at the width
79
+ * the option was last rendered at returns the stored rows.
80
+ *
81
+ * Untrusted boundary: `preview` is model-authored markdown and this is the only
82
+ * place it reaches the render path. A `Markdown` that throws (malformed input,
83
+ * width edge cases) must not take the overlay down with it, so the render is
84
+ * wrapped and a single dim fallback line is returned instead. There is no
85
+ * notify channel from the view layer — the fallback line IS the diagnostic.
86
+ * A failed render stores nothing, so the next frame tries again.
63
87
  */
64
88
  bodyFor(optionIndex: number, innerWidth: number): string[] {
65
- if (this.cachedWidth !== innerWidth) {
66
- for (const md of this.markdownCache.values()) {
67
- md.invalidate();
68
- }
69
- this.cachedWidth = innerWidth;
70
- }
71
89
  const text = this.previewTexts.get(optionIndex);
72
90
  if (!text) {
73
91
  const placeholder = this.theme.fg("dim", NO_PREVIEW_TEXT);
74
92
  const pad = Math.max(0, innerWidth - visibleWidth(placeholder));
75
93
  return [placeholder + " ".repeat(pad)];
76
94
  }
77
- let md = this.markdownCache.get(optionIndex);
78
- if (!md) {
79
- md = this.markdownFactory(text, this.markdownTheme);
80
- this.markdownCache.set(optionIndex, md);
95
+ let entry = this.markdownCache.get(optionIndex);
96
+ if (!entry) {
97
+ entry = {
98
+ md: this.markdownFactory(text, this.markdownTheme),
99
+ width: undefined,
100
+ lines: undefined,
101
+ };
102
+ this.markdownCache.set(optionIndex, entry);
103
+ }
104
+ if (entry.lines !== undefined && entry.width === innerWidth) {
105
+ // A copy: the rows travel into the box renderer and out to the pane, and
106
+ // the cache is the only thing that may hold the originals.
107
+ return [...entry.lines];
108
+ }
109
+ try {
110
+ const lines = stripFenceMarkers(entry.md.render(innerWidth));
111
+ entry.width = innerWidth;
112
+ entry.lines = lines;
113
+ return [...lines];
114
+ } catch {
115
+ entry.width = undefined;
116
+ entry.lines = undefined;
117
+ return [this.theme.fg("dim", PREVIEW_RENDER_FAILED_TEXT)];
81
118
  }
82
- return stripFenceMarkers(md.render(innerWidth));
83
119
  }
84
120
 
85
121
  invalidate(): void {
86
- for (const md of this.markdownCache.values()) {
87
- md.invalidate();
122
+ for (const entry of this.markdownCache.values()) {
123
+ entry.md.invalidate();
124
+ entry.width = undefined;
125
+ entry.lines = undefined;
88
126
  }
89
- this.cachedWidth = undefined;
90
127
  }
91
128
  }
@@ -197,6 +197,38 @@ export function crossTabLeftWidthWithDonation(
197
197
  return Math.min(Math.max(labelDriven, slackDonation), Math.max(1, ceiling));
198
198
  }
199
199
 
200
+ /**
201
+ * Widths kept before the memo below is dropped wholesale. A frame asks for one;
202
+ * the rest of the entries are old terminal sizes from a resize drag.
203
+ */
204
+ const PANE_WIDTH_MEMO_MAX_ENTRIES = 16;
205
+
206
+ /**
207
+ * Memoize a pane-width computation. For `crossTabLeftWidthWithDonation` and
208
+ * `crossTabPreviewBudget`, whose other inputs are fixed for the life of a
209
+ * questionnaire: both scan every tab, every option and every preview source
210
+ * line, and the pane width only changes on resize, yet the callers ask four to
211
+ * five times a frame (`focusedItemRowRange`, `naturalHeight`,
212
+ * `maxNaturalHeight`, `renderSideBySide`).
213
+ */
214
+ export function memoizeByPaneWidth(
215
+ compute: (paneWidth: number) => number,
216
+ ): (paneWidth: number) => number {
217
+ const cache = new Map<number, number>();
218
+ return (paneWidth: number): number => {
219
+ const hit = cache.get(paneWidth);
220
+ if (hit !== undefined) {
221
+ return hit;
222
+ }
223
+ const value = compute(paneWidth);
224
+ if (cache.size >= PANE_WIDTH_MEMO_MAX_ENTRIES) {
225
+ cache.clear();
226
+ }
227
+ cache.set(paneWidth, value);
228
+ return value;
229
+ };
230
+ }
231
+
200
232
  /**
201
233
  * Width allocation for side-by-side mode.
202
234
  * `adaptiveLeft` is the pre-computed left column width (from `adaptiveLeftWidth`,
@@ -59,7 +59,13 @@ export interface PreviewPaneProps {
59
59
 
60
60
  export interface PreviewPaneConfig {
61
61
  question: QuestionData;
62
- getTerminalWidth: () => number;
62
+ /**
63
+ * The frame's terminal width, snapshotted by `DialogView.render` before
64
+ * anything paints. Not a live read of `tui.terminal.columns`: two components
65
+ * deciding layout from two different readings of one frame is the bug this
66
+ * closes.
67
+ */
68
+ getFrameTerminalWidth: () => number;
63
69
  optionListView: OptionListView;
64
70
  previewBlock: PreviewBlockRenderer;
65
71
  }
@@ -74,11 +80,13 @@ export interface PreviewPaneConfig {
74
80
  *
75
81
  * `naturalHeight` and `maxNaturalHeight` query both children's heights; `render`
76
82
  * combines them via `decideLayout` (mode threaded into both calls — never
77
- * re-derived).
83
+ * re-derived). The probes ask `OptionListView.measureHeight`, not `render`, so
84
+ * one frame's three passes over the option list cost one wrap at each distinct
85
+ * width and no discarded rows.
78
86
  */
79
87
  export class PreviewPane implements StatefulView<PreviewPaneProps>, Component {
80
88
  private readonly question: QuestionData;
81
- private readonly getTerminalWidth: () => number;
89
+ private readonly getFrameTerminalWidth: () => number;
82
90
  private readonly optionListView: OptionListView;
83
91
  private readonly previewBlock: PreviewBlockRenderer;
84
92
  private props: PreviewPaneProps;
@@ -93,7 +101,7 @@ export class PreviewPane implements StatefulView<PreviewPaneProps>, Component {
93
101
 
94
102
  constructor(config: PreviewPaneConfig) {
95
103
  this.question = config.question;
96
- this.getTerminalWidth = config.getTerminalWidth;
104
+ this.getFrameTerminalWidth = config.getFrameTerminalWidth;
97
105
  this.optionListView = config.optionListView;
98
106
  this.previewBlock = config.previewBlock;
99
107
  this.props = { notesVisible: false, selectedIndex: 0, focused: false, inputMode: false };
@@ -134,7 +142,7 @@ export class PreviewPane implements StatefulView<PreviewPaneProps>, Component {
134
142
  return this.optionListView.render(width);
135
143
  }
136
144
 
137
- const mode = decideLayout(this.getTerminalWidth(), width);
145
+ const mode = decideLayout(this.getFrameTerminalWidth(), width);
138
146
  if (mode === "side-by-side") {
139
147
  return this.renderSideBySide(width, mode);
140
148
  }
@@ -165,7 +173,7 @@ export class PreviewPane implements StatefulView<PreviewPaneProps>, Component {
165
173
  if (this.props.inputMode) {
166
174
  return this.optionListView.focusedItemRowRange(width);
167
175
  }
168
- const mode = decideLayout(this.getTerminalWidth(), width);
176
+ const mode = decideLayout(this.getFrameTerminalWidth(), width);
169
177
  if (mode === "stacked") {
170
178
  return this.optionListView.focusedItemRowRange(width);
171
179
  }
@@ -176,20 +184,20 @@ export class PreviewPane implements StatefulView<PreviewPaneProps>, Component {
176
184
 
177
185
  naturalHeight(width: number): number {
178
186
  if (this.question.multiSelect === true) {
179
- return this.optionListView.render(width).length;
187
+ return this.optionListView.measureHeight(width);
180
188
  }
181
189
  if (!this.previewBlock.hasAnyPreview()) {
182
- return this.optionListView.render(width).length;
190
+ return this.optionListView.measureHeight(width);
183
191
  }
184
192
  // `inputMode`: height is the full-width option list only (no preview block) — preserves
185
193
  // the `naturalHeight === render.length` parity invariant.
186
194
  if (this.props.inputMode) {
187
- return this.optionListView.render(width).length;
195
+ return this.optionListView.measureHeight(width);
188
196
  }
189
- const mode = decideLayout(this.getTerminalWidth(), width);
197
+ const mode = decideLayout(this.getFrameTerminalWidth(), width);
190
198
  const adaptiveLeft = this.getAdaptiveLeft(width);
191
199
  const { optionsWidth, previewWidth } = bodyWidths(width, mode, adaptiveLeft);
192
- const optionsHeight = this.optionListView.render(optionsWidth).length;
200
+ const optionsHeight = this.optionListView.measureHeight(optionsWidth);
193
201
  const previewBlockHeight = this.previewBlock.blockHeight(
194
202
  previewWidth,
195
203
  this.props.selectedIndex,
@@ -203,20 +211,20 @@ export class PreviewPane implements StatefulView<PreviewPaneProps>, Component {
203
211
 
204
212
  maxNaturalHeight(width: number): number {
205
213
  if (this.question.multiSelect === true) {
206
- return this.optionListView.render(width).length;
214
+ return this.optionListView.measureHeight(width);
207
215
  }
208
216
  if (!this.previewBlock.hasAnyPreview()) {
209
- return this.optionListView.render(width).length;
217
+ return this.optionListView.measureHeight(width);
210
218
  }
211
219
  // `inputMode`: like naturalHeight — full-width option list only, so the
212
220
  // `maxNaturalHeight >= naturalHeight` parity invariant holds (both equal the list height).
213
221
  if (this.props.inputMode) {
214
- return this.optionListView.render(width).length;
222
+ return this.optionListView.measureHeight(width);
215
223
  }
216
- const mode = decideLayout(this.getTerminalWidth(), width);
224
+ const mode = decideLayout(this.getFrameTerminalWidth(), width);
217
225
  const adaptiveLeft = this.getAdaptiveLeft(width);
218
226
  const { optionsWidth, previewWidth } = bodyWidths(width, mode, adaptiveLeft);
219
- const optionsHeight = this.optionListView.render(optionsWidth).length;
227
+ const optionsHeight = this.optionListView.measureHeight(optionsWidth);
220
228
  let maxPreviewBlock = 0;
221
229
  for (let i = 0; i < this.question.options.length; i++) {
222
230
  const h = this.previewBlock.blockHeight(previewWidth, i, mode);
@@ -1,5 +1,5 @@
1
1
  import type { Theme } from "@earendil-works/pi-coding-agent";
2
- import { truncateToWidth } from "@earendil-works/pi-tui";
2
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
3
3
  import type { StatefulView } from "../stateful-view.js";
4
4
 
5
5
  /**
@@ -16,12 +16,13 @@ import type { StatefulView } from "../stateful-view.js";
16
16
  * than being appended, so a segment is the same width noted or not. Appending
17
17
  * it would add one cell per tab, and four tabs at the schema's 16-character
18
18
  * header limit already put this bar at 99 columns: four suffixes would push it
19
- * to 103, past the 100 columns previews require. `truncateToWidth` below is
20
- * called with an empty ellipsis and drops the tail, so what would go missing is
21
- * the Submit tab, silently.
19
+ * to 103, past the 100 columns previews require.
22
20
  */
23
21
  export const NOTED_MARKER = "*";
24
22
 
23
+ /** Shown in place of the question tabs that did not fit. */
24
+ export const TAB_OVERFLOW_ELLIPSIS = "…";
25
+
25
26
  export interface TabBarProps {
26
27
  /** One per author-defined question, in order. */
27
28
  tabs: ReadonlyArray<{ label: string; answered: boolean; active: boolean; noted: boolean }>;
@@ -44,6 +45,19 @@ export class TabBar implements StatefulView<TabBarProps> {
44
45
 
45
46
  invalidate(): void {}
46
47
 
48
+ /**
49
+ * Question tabs absorb the truncation; the Submit pill does not.
50
+ *
51
+ * Four 16-character headers put this bar past 99 columns, so a narrower
52
+ * terminal always drops something. Trimming the joined line from the right
53
+ * dropped Submit first, and a user who cannot see Submit cannot finish the
54
+ * questionnaire. The tail is reserved, the question tabs are trimmed to what
55
+ * is left, and `TAB_OVERFLOW_ELLIPSIS` marks the tabs that went missing.
56
+ *
57
+ * Below the tail's own width there is nothing left to reserve — the whole
58
+ * line is clipped, as before, so the caller's `visibleWidth <= width`
59
+ * invariant survives every terminal size.
60
+ */
47
61
  render(width: number): string[] {
48
62
  const pieces: string[] = [" ← "];
49
63
 
@@ -61,10 +75,14 @@ export class TabBar implements StatefulView<TabBarProps> {
61
75
  const submitStyled = this.props.submit.active
62
76
  ? this.theme.bg("selectedBg", this.theme.fg("text", submitText))
63
77
  : this.theme.fg(this.props.submit.allAnswered ? "success" : "dim", submitText);
64
- pieces.push(submitStyled);
65
- pieces.push(" →");
78
+ const tail = `${submitStyled} →`;
79
+ const tailWidth = visibleWidth(tail);
80
+ const head = pieces.join("");
66
81
 
67
- const tabLine = truncateToWidth(pieces.join(""), width, "");
68
- return [tabLine, ""];
82
+ if (width <= tailWidth) {
83
+ return [truncateToWidth(`${head}${tail}`, width, ""), ""];
84
+ }
85
+ const headLine = truncateToWidth(head, width - tailWidth, TAB_OVERFLOW_ELLIPSIS);
86
+ return [`${headLine}${tail}`, ""];
69
87
  }
70
88
  }