Skip to main content

tix/app/
pickers.rs

1#[allow(clippy::wildcard_imports)]
2use super::*;
3
4#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5pub enum FormPickerField {
6    Labels,
7    Assignee,
8    Cycles,
9    Milestones,
10    Reviewer,
11    /// The issue's board column (tix #1508). Single-select, options are the
12    /// active board's columns plus the field's current value. No inline create:
13    /// a column the board does not configure renders in the Mismatched column, and
14    /// inventing one from a form is not something the dropdown should do.
15    Status,
16    /// The issue's priority (tix #1508). Single-select, options are the built-in
17    /// `low`/`medium`/`high` plus every priority the store already uses, plus the
18    /// current value. Inline `+` create is kept: a custom priority is a preserved
19    /// concept in the model (`Priority::Other`), so a genuinely new one must
20    /// remain expressible.
21    Priority,
22    /// The new-issue templates configured in `[templates.<name>]`
23    /// (tui-new-issue-mode). Single-select: choosing one names a record to
24    /// pre-fill the form from.
25    Template,
26    /// The parent issue (WP06 / T026). Single-select, per C-001: one parent,
27    /// never a list, so this is `DropdownState::single` below and never `multi`.
28    Parent,
29    /// The issue's project associations (WP06 T029). **Multi-select**, because the
30    /// model and the CLI's `--projects` both hold a list, and the options come from
31    /// the project **service** ([`crate::projects::list_projects`]), not from
32    /// anything the form constructs. No inline create: a project is a registry
33    /// record with an identity, and `tix project create` is where one is made.
34    Project,
35    /// The issue's epic references (WP06 T031). **Multi-select**, the exact analogue
36    /// of `Project` for the epic registry (C-003): the model and the CLI's `--epics`
37    /// both hold a list, and the options come from the epic **service**
38    /// ([`crate::epics::list_epics`]). No inline create: an epic is a registry record
39    /// with an identity, and `tix epic create` is where one is made.
40    Epic,
41}
42
43#[derive(Debug, Clone)]
44pub(in crate::app) struct FormPicker {
45    pub(in crate::app) field: FormPickerField,
46    pub(in crate::app) state: DropdownState,
47}
48
49/// The prefix on a stable-ID template's picker option id (`[issue_templates.<id>]`).
50///
51/// A stable template ID and a legacy `[templates.<name>]` name live in two
52/// config tables that are documented never to share a key space, but a single
53/// picker presents both — so each option id is namespaced here and decoded on
54/// selection. That keeps "a name in one is never mistaken for an ID in the
55/// other" true *inside the picker* as well (registry module doc), and it is why
56/// the form stores a bare ID/name rather than this opaque token.
57pub(in crate::app) const STABLE_TEMPLATE_PREFIX: &str = "stable:";
58
59/// The prefix on a legacy `[templates.<name>]` picker option id. See
60/// [`STABLE_TEMPLATE_PREFIX`].
61pub(in crate::app) const LEGACY_TEMPLATE_PREFIX: &str = "legacy:";
62
63impl FormPickerField {
64    /// Whether the picker's `+` inline create is offered on this field.
65    ///
66    /// Every field but Status keeps the keystroke it had: cycles, milestones and
67    /// team members become configuration records, labels and priorities are
68    /// preserved values, and parent/template simply no-op downstream. A board
69    /// column cannot be created from the form (tix #1508) — a status the board
70    /// does not configure renders in the Mismatched column, so the dropdown must
71    /// not pretend to add one.
72    pub(in crate::app) fn allows_inline_create(self) -> bool {
73        // A project is a registry record with an identity the service owns, so the
74        // form may no more invent one than it may invent a board column: `+` here
75        // would be a project `tix project list` never shows.
76        !matches!(
77            self,
78            FormPickerField::Status | FormPickerField::Project | FormPickerField::Epic
79        )
80    }
81}
82
83impl App {
84    pub(in crate::app) fn handle_form_picker_key(&mut self, key: KeyEvent) -> Result<Action> {
85        if matches!(key.code, KeyCode::Esc | KeyCode::F(2)) {
86            self.form_picker = None;
87            return Ok(Action::None);
88        }
89        // The Status dropdown refuses inline create (tix #1508): a board column the
90        // form invents is not a column the board has, and it would render in the
91        // Mismatched column rather than somewhere real. Swallow the create keys
92        // before `DropdownState::handle_key` turns them into an InlineCreate prompt.
93        if let Some(picker) = self.form_picker.as_ref()
94            && !picker.field.allows_inline_create()
95            && picker.state.mode != PickerMode::InlineCreate
96            && matches!(key.code, KeyCode::Char('+') | KeyCode::Insert)
97        {
98            return Ok(Action::None);
99        }
100        let event = match self.form_picker.as_mut() {
101            Some(picker) => picker.state.handle_key(key.code),
102            None => return Ok(Action::None),
103        };
104        match event {
105            PickerEvent::None => {}
106            PickerEvent::Cancelled => {
107                self.form_picker = None;
108            }
109            PickerEvent::Selected(ids) => {
110                self.apply_picker_selection(ids);
111            }
112            PickerEvent::Created(value) => {
113                self.apply_picker_created(value)?;
114            }
115        }
116        Ok(Action::None)
117    }
118
119    pub(in crate::app) fn open_form_picker(&mut self) {
120        let Some(form) = self.form.as_ref() else {
121            return;
122        };
123        let field = match form.focus {
124            form::FormFocus::Status => FormPickerField::Status,
125            form::FormFocus::Priority => FormPickerField::Priority,
126            form::FormFocus::Labels => FormPickerField::Labels,
127            form::FormFocus::Assignee => FormPickerField::Assignee,
128            form::FormFocus::Cycles => FormPickerField::Cycles,
129            form::FormFocus::Milestones => FormPickerField::Milestones,
130            form::FormFocus::Reviewer => FormPickerField::Reviewer,
131            form::FormFocus::Parent => FormPickerField::Parent,
132            form::FormFocus::Projects => FormPickerField::Project,
133            form::FormFocus::Epics => FormPickerField::Epic,
134            form::FormFocus::Template => FormPickerField::Template,
135            _ => return,
136        };
137        // The template picker has its own opener (`open_template_picker`), which
138        // seeds the `(none)` option and refuses when nothing is configured; F2 on
139        // the row routes there rather than duplicating the fallback here.
140        if field == FormPickerField::Template {
141            self.open_template_picker();
142            return;
143        }
144        let options = self.picker_options(field);
145        let mut state = match field {
146            FormPickerField::Labels
147            | FormPickerField::Cycles
148            | FormPickerField::Milestones
149            | FormPickerField::Project
150            | FormPickerField::Epic => DropdownState::multi(options),
151            FormPickerField::Assignee
152            | FormPickerField::Reviewer
153            | FormPickerField::Parent
154            | FormPickerField::Status
155            | FormPickerField::Priority
156            | FormPickerField::Template => DropdownState::single(options),
157        };
158        self.seed_picker_selection(field, &mut state);
159        self.form_picker = Some(FormPicker { field, state });
160    }
161
162    #[allow(clippy::too_many_lines)]
163    pub(in crate::app) fn picker_options(&self, field: FormPickerField) -> Vec<PickerItem> {
164        match field {
165            FormPickerField::Cycles => {
166                let root = self.project_root();
167                let planner = Planner::from_config(self.store.config.clone(), root);
168                planner
169                    .cycle_names()
170                    .into_iter()
171                    .map(|name| PickerItem {
172                        id: name.clone(),
173                        label: name,
174                    })
175                    .collect()
176            }
177            FormPickerField::Milestones => {
178                let root = self.project_root();
179                let planner = Planner::from_config(self.store.config.clone(), root);
180                planner
181                    .milestone_names()
182                    .unwrap_or_default()
183                    .into_iter()
184                    .map(|name| PickerItem {
185                        id: name.clone(),
186                        label: name,
187                    })
188                    .collect()
189            }
190            FormPickerField::Labels => {
191                let mut out = std::collections::BTreeSet::new();
192                for issue in self.store.list().unwrap_or_default() {
193                    for label in issue.labels {
194                        if !label.trim().is_empty() {
195                            out.insert(label);
196                        }
197                    }
198                }
199                out.into_iter()
200                    .map(|label| PickerItem {
201                        id: label.clone(),
202                        label,
203                    })
204                    .collect()
205            }
206            // The board's columns, in board order, plus the field's current value
207            // when the board no longer configures it. A status the board has
208            // dropped must not become unselectable from a form that still holds
209            // it (tix #1508) — the same "keep the current value" rule the assignee
210            // and reviewer pickers follow.
211            FormPickerField::Status => {
212                let columns = self.active_board_config().columns.clone();
213                let mut options = crate::ui::triage::status_options(&columns);
214                if let Some(form) = self.form.as_ref() {
215                    let current = form.status.trim();
216                    if !current.is_empty() && !columns.iter().any(|c| c == current) {
217                        options.push(PickerItem {
218                            id: current.to_string(),
219                            label: current.to_string(),
220                        });
221                    }
222                }
223                options
224            }
225            // The built-in set first, then every priority the store already uses,
226            // then the field's current value — the same "defaults plus what the
227            // project actually uses" rule Labels follows. A custom priority is a
228            // preserved model value (`Priority::Other`), so it must be listed once
229            // an issue carries it rather than being re-typed from memory.
230            FormPickerField::Priority => {
231                const BUILT_INS: [&str; 3] = ["low", "medium", "high"];
232                let mut out: Vec<String> = BUILT_INS.iter().map(|p| (*p).to_string()).collect();
233                for issue in self.store.list().unwrap_or_default() {
234                    let priority = issue.priority.to_string();
235                    if !priority.trim().is_empty() && !out.contains(&priority) {
236                        out.push(priority);
237                    }
238                }
239                if let Some(form) = self.form.as_ref() {
240                    let current = form.priority.trim().to_string();
241                    if !current.is_empty() && !out.contains(&current) {
242                        out.push(current);
243                    }
244                }
245                out.into_iter()
246                    .map(|priority| PickerItem {
247                        id: priority.clone(),
248                        label: priority,
249                    })
250                    .collect()
251            }
252            FormPickerField::Parent => {
253                // Every issue in the store is a candidate parent.
254                //
255                // T026's note asks for the issue itself to be excluded from the
256                // list, as "the first line of defense against a self-parent". The
257                // form is **create-only** — `Mode::Form` has one entry point and it
258                // always builds a fresh `FormState` — so there is no issue-under-edit
259                // to exclude: the child does not exist yet and cannot be among its
260                // own ancestors. The list is therefore unfiltered here, and the
261                // defense is `hierarchy::validate_new_parent`, which runs at submit.
262                // (Tracked as tix #1384: once an edit form exists, this arm must
263                // exclude the edited issue, and `set_parent` becomes the write.)
264                self.store
265                    .list()
266                    .unwrap_or_default()
267                    .into_iter()
268                    .map(|issue| PickerItem {
269                        id: issue.number.to_string(),
270                        label: format!("#{} {}", issue.number, issue.title),
271                    })
272                    .collect()
273            }
274            // Unreachable in practice: `open_form_picker` routes the Template row
275            // to `open_template_picker`, which owns the `(none)` option. Kept
276            // exhaustive and honest — the options *are* the configured templates.
277            FormPickerField::Template => self.template_picker_options(),
278            // The **service's** project list, in declaration order. The option id is
279            // the resolved project id (what the model stores and what the filter
280            // matches), and the label is the shared `name (id)` display form, so the
281            // picker and `tix project list` cannot spell a project two ways. Nothing
282            // here constructs or validates a project: a reference the registry does
283            // not hold never appears, and a stored reference that no longer resolves
284            // is kept visible by the current-value rule below
285            // (`seed_picker_selection` seeds it, and `DropdownState` lists a seeded
286            // value even when no option carries it).
287            FormPickerField::Project => {
288                let mut options: Vec<PickerItem> = self
289                    .live_projects()
290                    .into_iter()
291                    .map(|project| PickerItem {
292                        id: project.id.to_string(),
293                        label: crate::projects::project_display(&project.id, &project.name),
294                    })
295                    .collect();
296                if let Some(form) = self.form.as_ref() {
297                    for current in form.projects.split(',').map(str::trim) {
298                        if !current.is_empty() && !options.iter().any(|o| o.id == current) {
299                            options.push(PickerItem {
300                                id: current.to_string(),
301                                label: format!("{current} (missing project)"),
302                            });
303                        }
304                    }
305                }
306                options
307            }
308            // The **service's** epic list, in declaration order — the exact analogue
309            // of the Project arm above. The option id is the resolved epic id (what
310            // the model stores and what the filter matches), and the label is the
311            // shared `name (id)` display form, so the picker and `tix epic list`
312            // cannot spell an epic two ways. Nothing here constructs or validates an
313            // epic: a reference the registry does not hold never appears, and a stored
314            // reference that no longer resolves is kept visible by the current-value
315            // rule below.
316            //
317            // No "exclude the issue itself" filter, and the reason is the Parent
318            // picker's: the form is **create-only** (`Mode::Form` always builds a
319            // fresh `FormState`), so there is no issue-under-edit to exclude, and an
320            // issue→epic reference cannot close a cycle in the epic **parent**
321            // hierarchy anyway — the two are different graphs (contracts/epic-
322            // service.md §3). A reference the service rejects (an unknown id, or a
323            // cycle a future edit form introduces) is refused at submit through the
324            // service's own diagnostic, which is the rule this row must not restate.
325            FormPickerField::Epic => {
326                let mut options: Vec<PickerItem> = self
327                    .live_epics()
328                    .into_iter()
329                    .map(|epic| PickerItem {
330                        id: epic.id.to_string(),
331                        label: crate::epics::epic_display(&epic.id, &epic.name),
332                    })
333                    .collect();
334                if let Some(form) = self.form.as_ref() {
335                    for current in form.epics.split(',').map(str::trim) {
336                        if !current.is_empty() && !options.iter().any(|o| o.id == current) {
337                            options.push(PickerItem {
338                                id: current.to_string(),
339                                label: format!("{current} (missing epic)"),
340                            });
341                        }
342                    }
343                }
344                options
345            }
346            FormPickerField::Assignee | FormPickerField::Reviewer => {
347                let mut out = std::collections::BTreeSet::new();
348                for team in self.store.config.teams.values() {
349                    for member in &team.members {
350                        if !member.trim().is_empty() {
351                            out.insert(member.trim().to_string());
352                        }
353                    }
354                }
355                if let Some(id) = git_identity() {
356                    out.insert(id.to_string());
357                }
358                if let Some(form) = self.form.as_ref() {
359                    let current = match field {
360                        FormPickerField::Assignee => form.assignee.trim(),
361                        FormPickerField::Reviewer => form.reviewer.trim(),
362                        _ => "",
363                    };
364                    if !current.is_empty() {
365                        out.insert(current.to_string());
366                    }
367                }
368                out.into_iter()
369                    .map(|member| PickerItem {
370                        id: member.clone(),
371                        label: member,
372                    })
373                    .collect()
374            }
375        }
376    }
377
378    pub(in crate::app) fn seed_picker_selection(
379        &self,
380        field: FormPickerField,
381        picker: &mut DropdownState,
382    ) {
383        let Some(form) = self.form.as_ref() else {
384            return;
385        };
386        let values: Vec<String> = match field {
387            FormPickerField::Labels => form
388                .labels
389                .split(',')
390                .map(str::trim)
391                .filter(|s| !s.is_empty())
392                .map(str::to_string)
393                .collect(),
394            FormPickerField::Cycles => form
395                .cycles
396                .split(',')
397                .map(str::trim)
398                .filter(|s| !s.is_empty())
399                .map(str::to_string)
400                .collect(),
401            FormPickerField::Milestones => form
402                .milestones
403                .split(',')
404                .map(str::trim)
405                .filter(|s| !s.is_empty())
406                .map(str::to_string)
407                .collect(),
408            // WP06 T029 step 2: the pre-selected set is the issue's current
409            // associations, in the same comma-joined shape the submit path parses.
410            FormPickerField::Project => form
411                .projects
412                .split(',')
413                .map(str::trim)
414                .filter(|s| !s.is_empty())
415                .map(str::to_string)
416                .collect(),
417            // WP06 T031 step 2: the exact analogue for epic references — the
418            // pre-selected set is the issue's current references, so an edit cannot
419            // silently drop the ones the user did not touch.
420            FormPickerField::Epic => form
421                .epics
422                .split(',')
423                .map(str::trim)
424                .filter(|s| !s.is_empty())
425                .map(str::to_string)
426                .collect(),
427            FormPickerField::Assignee => form
428                .assignee
429                .trim()
430                .split(',')
431                .map(str::trim)
432                .filter(|s| !s.is_empty())
433                .map(str::to_string)
434                .take(1)
435                .collect(),
436            FormPickerField::Reviewer => form
437                .reviewer
438                .trim()
439                .split(',')
440                .map(str::trim)
441                .filter(|s| !s.is_empty())
442                .map(str::to_string)
443                .take(1)
444                .collect(),
445            // Status and Priority are single values, held whole (a status can
446            // contain spaces, so splitting on commas would be wrong), and an
447            // empty field simply starts the dropdown with nothing selected.
448            FormPickerField::Status => (!form.status.trim().is_empty())
449                .then(|| form.status.trim().to_string())
450                .into_iter()
451                .collect(),
452            FormPickerField::Priority => (!form.priority.trim().is_empty())
453                .then(|| form.priority.trim().to_string())
454                .into_iter()
455                .collect(),
456            // Already a resolved number, so no comma-splitting: there is exactly
457            // one parent, and splitting it would corrupt a value that is legal.
458            FormPickerField::Parent => (!form.parent.trim().is_empty())
459                .then(|| form.parent.trim().to_string())
460                .into_iter()
461                .collect(),
462            // Empty is `(none)`, which matches the option's empty id, so the
463            // picker opens on the current selection rather than at the top. A
464            // stable-ID selection is namespaced like its option; a legacy one
465            // likewise. See [`STABLE_TEMPLATE_PREFIX`].
466            FormPickerField::Template => {
467                match (form.template_scope.as_deref(), form.template.trim()) {
468                    (Some(_), id) if !id.is_empty() => {
469                        vec![format!("{STABLE_TEMPLATE_PREFIX}{id}")]
470                    }
471                    (None, name) if !name.is_empty() => {
472                        vec![format!("{LEGACY_TEMPLATE_PREFIX}{name}")]
473                    }
474                    _ => Vec::new(),
475                }
476            }
477        };
478        match &mut picker.selection {
479            SelectionModel::Single(current) => {
480                *current = values.first().cloned();
481            }
482            SelectionModel::Multi(current) => {
483                current.clear();
484                current.extend(values);
485            }
486        }
487    }
488
489    pub(in crate::app) fn apply_picker_selection(&mut self, values: Vec<String>) {
490        let Some(field) = self.form_picker.as_ref().map(|p| p.field) else {
491            return;
492        };
493        // Templates are single-select *and* choosing one ends the interaction: the
494        // picker closes and the form is pre-filled. The multi-select fields below
495        // stay open on purpose, so several labels/cycles can be picked in one go.
496        if field == FormPickerField::Template {
497            let id = values.first().cloned().unwrap_or_default();
498            self.form_picker = None;
499            self.apply_template_choice(&id);
500            return;
501        }
502        let Some(form) = self.form.as_mut() else {
503            return;
504        };
505        match field {
506            FormPickerField::Labels => form.labels = values.join(","),
507            FormPickerField::Assignee => {
508                form.assignee = values.first().cloned().unwrap_or_default()
509            }
510            FormPickerField::Cycles => form.cycles = values.join(","),
511            FormPickerField::Milestones => form.milestones = values.join(","),
512            // WP06 T029: the multi-selection is the association set, written back in
513            // the same comma-joined shape every other list field uses. The picker is
514            // the only way this row changes, and the ids came from the service, so
515            // the form has constructed nothing.
516            FormPickerField::Project => form.projects = values.join(","),
517            // WP06 T031: the multi-selection is the reference set, written back in
518            // the same comma-joined shape every other list field uses. The picker is
519            // the only way this row changes, and the ids came from the service, so
520            // the form has constructed nothing.
521            FormPickerField::Epic => form.epics = values.join(","),
522            // Single-select: a Status/Priority dropdown writes the one chosen
523            // value, and no selection clears the row, which is the same "empty
524            // means unset" the CLI accepts.
525            FormPickerField::Status => form.status = values.first().cloned().unwrap_or_default(),
526            FormPickerField::Priority => {
527                form.priority = values.first().cloned().unwrap_or_default()
528            }
529            FormPickerField::Reviewer => {
530                form.reviewer = values.first().cloned().unwrap_or_default()
531            }
532            // Single-select: at most one value, and no selection clears the field,
533            // which is the same "empty means none" the CLI's `--parent ""` means.
534            FormPickerField::Parent => form.parent = values.first().cloned().unwrap_or_default(),
535            // Handled before the `form` borrow above: choosing a template closes
536            // the picker and re-fills the form through `apply_template_choice`.
537            FormPickerField::Template => {}
538        }
539        // A picker replaces the value wholesale, so the insertion point belongs
540        // after it rather than wherever it sat in the text it replaced.
541        form.cursor_to_end();
542    }
543
544    pub(in crate::app) fn apply_picker_created(&mut self, value: String) -> Result<()> {
545        let value = value.trim().to_string();
546        if value.is_empty() {
547            return Ok(());
548        }
549        let Some(field) = self.form_picker.as_ref().map(|p| p.field) else {
550            return Ok(());
551        };
552        match field {
553            FormPickerField::Cycles => {
554                let root = self.project_root();
555                let mut planner = Planner::from_config(self.store.config.clone(), root.clone());
556                match planner.add_cycle(Some(value.clone())) {
557                    Ok(_) => {
558                        self.reload_config_from(root.as_path())?;
559                    }
560                    Err(e) => {
561                        self.status_msg = Some(e.to_string());
562                        return Ok(());
563                    }
564                }
565            }
566            FormPickerField::Milestones => {
567                let root = self.project_root();
568                let planner = Planner::from_config(self.store.config.clone(), root.clone());
569                match planner.create_milestone_file(&value, None, None) {
570                    Ok(_) => {
571                        self.reload_config_from(root.as_path())?;
572                    }
573                    Err(e) => {
574                        self.status_msg = Some(e.to_string());
575                        return Ok(());
576                    }
577                }
578            }
579            FormPickerField::Assignee | FormPickerField::Reviewer => {
580                let root = self.project_root();
581                let mut planner = Planner::from_config(self.store.config.clone(), root.clone());
582                let team = self
583                    .store
584                    .config
585                    .planning
586                    .default_team
587                    .clone()
588                    .unwrap_or_else(|| "default".to_string());
589                match planner.add_team_member(&team, &value) {
590                    Ok(()) => {
591                        self.reload_config_from(root.as_path())?;
592                    }
593                    Err(e) => {
594                        self.status_msg = Some(e.to_string());
595                        return Ok(());
596                    }
597                }
598            }
599            // `+` is inline-create: it makes a *new* cycle, milestone, or team
600            // member, and appends it to the list. A parent is not something you
601            // make from inside another issue's form — it is an issue that already
602            // exists — so the keystroke does nothing here, exactly as it does for
603            // Labels. Creating an empty issue to stand in for a parent would be
604            // worse than nothing: it would write a file the user never asked for.
605            // Templates are configuration, not form values: `+` cannot invent one
606            // any more than it can invent a parent. It is edited in `tix.toml`.
607            // A priority has no configuration record to write either — the custom
608            // value *is* the form value, and it is applied through the selection
609            // below like any picked option. Status never reaches here: its dropdown
610            // refuses `+`, because a column the board does not configure would
611            // render in the Mismatched column rather than becoming a choice.
612            FormPickerField::Labels
613            | FormPickerField::Parent
614            | FormPickerField::Template
615            | FormPickerField::Status
616            | FormPickerField::Priority
617            | FormPickerField::Project
618            | FormPickerField::Epic => {}
619        }
620
621        let next_options = self.picker_options(field);
622        if let Some(picker) = self.form_picker.as_mut() {
623            picker.state.options = next_options;
624            picker.state.filter.clear();
625            picker.state.cursor = 0;
626            picker.state.mode = PickerMode::Idle;
627            picker.state.create_buffer.clear();
628            match &mut picker.state.selection {
629                SelectionModel::Single(current) => *current = Some(value.clone()),
630                SelectionModel::Multi(current) => {
631                    current.insert(value.clone());
632                }
633            }
634            let values = match &picker.state.selection {
635                SelectionModel::Single(current) => current.iter().cloned().collect(),
636                SelectionModel::Multi(current) => current.iter().cloned().collect(),
637            };
638            self.apply_picker_selection(values);
639        }
640        Ok(())
641    }
642
643    /// The picker options for the Template row: the stable-ID registry
644    /// (`[issue_templates.<id>]`) first, then the legacy `[templates.<name>]`
645    /// pre-fill names.
646    ///
647    /// Each option's id is namespaced ([`STABLE_TEMPLATE_PREFIX`] /
648    /// [`LEGACY_TEMPLATE_PREFIX`]) so the two config key spaces stay distinct
649    /// inside one picker. A stable label shows the template's **display name and
650    /// explicit scope** — registry metadata presented for selection, never
651    /// interpreted (WP05/T023, T026). The stable ID is what a selection records,
652    /// never the display name (C-002).
653    pub(in crate::app) fn template_picker_options(&self) -> Vec<PickerItem> {
654        let mut options: Vec<PickerItem> = self
655            .store
656            .config
657            .issue_templates
658            .iter()
659            .map(|(id, template)| PickerItem {
660                id: format!("{STABLE_TEMPLATE_PREFIX}{id}"),
661                label: format!(
662                    "{} — {} [{}]",
663                    id,
664                    template.display_name(id),
665                    template.scope
666                ),
667            })
668            .collect();
669        options.extend(self.store.config.templates.keys().map(|name| PickerItem {
670            id: format!("{LEGACY_TEMPLATE_PREFIX}{name}"),
671            label: name.clone(),
672        }));
673        options
674    }
675}